Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion lib/src/components/theme-picker/ThemeStoreDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@ vi.mock('../../lib/themes', () => ({
setActiveThemeId: vi.fn(),
}));

import { searchThemes } from '../../lib/themes';
import { searchThemes, type OpenVSXExtension } from '../../lib/themes';
import { ThemeStoreDialog } from './ThemeStoreDialog';
import { setNativeFieldValue } from '../../lib/dom';

const searchThemesMock = vi.mocked(searchThemes);

function extension(name: string, displayName: string): OpenVSXExtension {
return {
namespace: 'test',
name,
displayName,
description: '',
version: '1.0.0',
downloadCount: 1,
};
}

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

// jsdom does not implement the native <dialog> modal methods.
Expand Down Expand Up @@ -89,4 +100,89 @@ describe('ThemeStoreDialog', () => {
vi.useRealTimers();
}
});

it('discards a search already in flight when the store closes', async () => {
let resolveSearch!: (result: { extensions: OpenVSXExtension[] }) => void;
searchThemesMock.mockImplementationOnce(
() => new Promise((resolve) => { resolveSearch = resolve; }),
);

vi.useFakeTimers();
try {
render(true);
typeQuery('dracula');
act(() => { vi.advanceTimersByTime(300); }); // the request leaves
expect(searchThemesMock).toHaveBeenCalledTimes(1);

render(false); // close while it is still in flight
await act(async () => {
resolveSearch({ extensions: [extension('dracula', 'Dracula Official')] });
});
render(true);

expect(container.textContent).not.toContain('Dracula Official');
expect(container.textContent).toContain('Search for a VS Code theme to install');
} finally {
vi.useRealTimers();
}
});

it('stops searching when the box is emptied while a request is in flight', async () => {
let resolveSearch!: (result: { extensions: OpenVSXExtension[] }) => void;
searchThemesMock.mockImplementationOnce(
() => new Promise((resolve) => { resolveSearch = resolve; }),
);

vi.useFakeTimers();
try {
render(true);
typeQuery('dracula');
act(() => { vi.advanceTimersByTime(300); });

// Emptying the box supersedes the in-flight request, so nothing it
// resolves with may show — but the spinner it turned on must still go.
typeQuery('');
act(() => { vi.advanceTimersByTime(300); });
await act(async () => {
resolveSearch({ extensions: [extension('dracula', 'Dracula Official')] });
});

expect(container.textContent).not.toContain('Searching...');
expect(container.textContent).not.toContain('Dracula Official');
expect(container.textContent).toContain('Search for a VS Code theme to install');
} finally {
vi.useRealTimers();
}
});

it('ignores a superseded search whose response arrives last', async () => {
let resolveFirst!: (result: { extensions: OpenVSXExtension[] }) => void;
let resolveSecond!: (result: { extensions: OpenVSXExtension[] }) => void;
searchThemesMock
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve; }));

vi.useFakeTimers();
try {
render(true);
typeQuery('dra');
act(() => { vi.advanceTimersByTime(300); });
typeQuery('nord');
act(() => { vi.advanceTimersByTime(300); });
expect(searchThemesMock).toHaveBeenCalledTimes(2);

// The newer query answers first; the older one lands afterwards.
await act(async () => {
resolveSecond({ extensions: [extension('nord', 'Nord Theme')] });
});
await act(async () => {
resolveFirst({ extensions: [extension('dracula', 'Dracula Official')] });
});

expect(container.textContent).toContain('Nord Theme');
expect(container.textContent).not.toContain('Dracula Official');
} finally {
vi.useRealTimers();
}
});
});
19 changes: 16 additions & 3 deletions lib/src/components/theme-picker/ThemeStoreDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export function ThemeStoreDialog({
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
// Every search takes a new epoch, and so does every close, so only the newest
// search may write its outcome. Cancelling the debounce below stops the
// searches that have not started yet; a search already in flight needs this,
// in two shapes — an earlier query answering after a later one, and a request
// outliving the close, repopulating the slate the reset effect just cleared.
const searchEpoch = useRef(0);

useEffect(() => {
const dialog = dialogRef.current;
Expand All @@ -46,6 +52,7 @@ export function ThemeStoreDialog({
// Cancel any debounce scheduled by the last keystroke; otherwise it fires
// doSearch after close and repopulates results/loading for the old query.
if (debounceRef.current) clearTimeout(debounceRef.current);
searchEpoch.current += 1;
setQuery('');
setResults([]);
setError(null);
Expand All @@ -60,19 +67,25 @@ export function ThemeStoreDialog({
}, []);

const doSearch = useCallback(async (value: string) => {
const epoch = ++searchEpoch.current;
const newest = () => searchEpoch.current === epoch;
if (!value.trim()) {
setResults([]);
// An emptied box is the newest search, so it inherits the spinner any
// request it just superseded turned on: that request's `finally` is
// gated off, and nothing else here would clear `loading`.
setLoading(false);
return;
Comment thread
dormouse-bot marked this conversation as resolved.
}
setLoading(true);
setError(null);
try {
const response = await searchThemes(value, 0, 20);
setResults(response.extensions);
if (newest()) setResults(response.extensions);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Search failed');
if (newest()) setError(reason instanceof Error ? reason.message : 'Search failed');
} finally {
setLoading(false);
if (newest()) setLoading(false);
}
}, []);

Expand Down
Loading