diff --git a/lib/src/components/theme-picker/ThemeStoreDialog.test.tsx b/lib/src/components/theme-picker/ThemeStoreDialog.test.tsx index f5cec80b4..2b68cf09d 100644 --- a/lib/src/components/theme-picker/ThemeStoreDialog.test.tsx +++ b/lib/src/components/theme-picker/ThemeStoreDialog.test.tsx @@ -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 modal methods. @@ -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(); + } + }); }); diff --git a/lib/src/components/theme-picker/ThemeStoreDialog.tsx b/lib/src/components/theme-picker/ThemeStoreDialog.tsx index f56d878cf..426170011 100644 --- a/lib/src/components/theme-picker/ThemeStoreDialog.tsx +++ b/lib/src/components/theme-picker/ThemeStoreDialog.tsx @@ -28,6 +28,12 @@ export function ThemeStoreDialog({ const [error, setError] = useState(null); const debounceRef = useRef | null>(null); const dialogRef = useRef(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; @@ -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); @@ -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; } 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); } }, []);