fix(extensions): ignore stale MCP search responses
This commit is contained in:
parent
495d8514c1
commit
113f9105fb
2 changed files with 141 additions and 3 deletions
|
|
@ -58,6 +58,7 @@ export function useExtensionsTabState() {
|
|||
|
||||
// ── Debounced MCP search ──
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof useExtensionsTabState>;
|
||||
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue