diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index ce71077d..6cb9375e 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -58,6 +58,7 @@ export function useExtensionsTabState() { // ── Debounced MCP search ── const searchTimerRef = useRef | null>(null); + const mcpSearchRequestSeqRef = useRef(0); // Cleanup timer on unmount useEffect(() => { @@ -65,6 +66,7 @@ export function useExtensionsTabState() { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } + mcpSearchRequestSeqRef.current += 1; }; }, []); @@ -82,6 +84,7 @@ export function useExtensionsTabState() { const mcpSearch = useCallback((query: string) => { setMcpSearchQuery(query); + const requestId = ++mcpSearchRequestSeqRef.current; if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); @@ -98,17 +101,25 @@ export function useExtensionsTabState() { searchTimerRef.current = setTimeout(() => { if (!api.mcpRegistry) { - setMcpSearchLoading(false); + if (mcpSearchRequestSeqRef.current === requestId) { + setMcpSearchLoading(false); + } return; } void api.mcpRegistry.search(query).then( (result: McpSearchResult) => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchResults(result.servers); setMcpSearchWarnings(result.warnings); setMcpSearchLoading(false); }, () => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchLoading(false); setMcpSearchWarnings(['Search failed']); } diff --git a/test/renderer/hooks/useExtensionsTabState.test.ts b/test/renderer/hooks/useExtensionsTabState.test.ts index 52ae6fbb..340d2849 100644 --- a/test/renderer/hooks/useExtensionsTabState.test.ts +++ b/test/renderer/hooks/useExtensionsTabState.test.ts @@ -1,16 +1,21 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState'; +import type { McpCatalogItem } from '@shared/types/extensions'; + type ExtensionsTabState = ReturnType; let capturedState: ExtensionsTabState | null = null; +const mcpSearchMock = vi.fn(); vi.mock('@renderer/api', () => ({ api: { - mcpRegistry: null, + mcpRegistry: { + search: (...args: unknown[]) => mcpSearchMock(...args), + }, }, })); @@ -19,10 +24,39 @@ function Harness(): null { return null; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeMcpServer(id: string): McpCatalogItem { + return { + id, + name: id, + description: `${id} description`, + source: 'official', + installSpec: null, + envVars: [], + tools: [], + requiresAuth: false, + }; +} + describe('useExtensionsTabState', () => { + beforeEach(() => { + mcpSearchMock.mockReset(); + mcpSearchMock.mockResolvedValue({ servers: [], warnings: [] }); + }); + afterEach(() => { capturedState = null; document.body.innerHTML = ''; + vi.useRealTimers(); }); it('clears selected plugin when leaving the plugins sub-tab', async () => { @@ -119,4 +153,97 @@ describe('useExtensionsTabState', () => { await Promise.resolve(); }); }); + + it('ignores stale MCP search responses that resolve out of order', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const first = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + const second = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + + mcpSearchMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.mcpSearch('first'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + capturedState?.mcpSearch('second'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + second.resolve({ servers: [makeMcpServer('second-result')], warnings: ['new warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + first.resolve({ servers: [makeMcpServer('first-result')], warnings: ['old warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('drops in-flight MCP search results after clearing the query', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const pending = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + mcpSearchMock.mockReturnValueOnce(pending.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.mcpSearch('context7'); + await vi.advanceTimersByTimeAsync(300); + }); + expect(capturedState?.mcpSearchLoading).toBe(true); + + await act(async () => { + capturedState?.mcpSearch(''); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchQuery).toBe(''); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + pending.resolve({ servers: [makeMcpServer('stale-result')], warnings: ['stale warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });