diff --git a/packages/ui/src/stores/useFileSearchStore.test.ts b/packages/ui/src/stores/useFileSearchStore.test.ts new file mode 100644 index 00000000..59fa60c1 --- /dev/null +++ b/packages/ui/src/stores/useFileSearchStore.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +const searchRequests: Array>> = []; + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const searchFilesMock = mock(() => { + const request = createDeferred>(); + searchRequests.push(request); + return request.promise; +}); + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + searchFiles: searchFilesMock, + }, +})); + +const { useFileSearchStore } = await import('./useFileSearchStore'); + +describe('useFileSearchStore', () => { + beforeEach(() => { + searchRequests.length = 0; + useFileSearchStore.setState({ + cache: {}, + cacheKeys: [], + inFlight: {}, + }); + }); + + test('does not cache a stale in-flight search after invalidation', async () => { + const searchPromise = useFileSearchStore.getState().searchFiles('/project', 'foo'); + expect(Object.keys(useFileSearchStore.getState().inFlight)).toHaveLength(1); + + useFileSearchStore.getState().invalidateDirectory('/project'); + expect(Object.keys(useFileSearchStore.getState().inFlight)).toHaveLength(0); + + searchRequests[0].resolve([{ path: 'stale.ts' }]); + await searchPromise; + + expect(useFileSearchStore.getState().cache).toEqual({}); + expect(useFileSearchStore.getState().cacheKeys).toEqual([]); + }); + + test('does not notify subscribers when stale search handlers make no state change', async () => { + const searchPromise = useFileSearchStore.getState().searchFiles('/project', 'foo'); + useFileSearchStore.getState().invalidateDirectory('/project'); + + let updateCount = 0; + const unsubscribe = useFileSearchStore.subscribe(() => { + updateCount += 1; + }); + + searchRequests[0].resolve([{ path: 'stale.ts' }]); + await searchPromise; + unsubscribe(); + + expect(updateCount).toBe(0); + }); + + test('does not let a stale request remove a newer in-flight search', async () => { + const stalePromise = useFileSearchStore.getState().searchFiles('/project', 'foo'); + useFileSearchStore.getState().invalidateDirectory('/project'); + const freshPromise = useFileSearchStore.getState().searchFiles('/project', 'foo'); + + searchRequests[0].resolve([{ path: 'stale.ts' }]); + await stalePromise; + + expect(Object.keys(useFileSearchStore.getState().inFlight)).toHaveLength(1); + + searchRequests[1].resolve([{ path: 'fresh.ts' }]); + await freshPromise; + + const cacheEntries = Object.values(useFileSearchStore.getState().cache); + expect(cacheEntries).toHaveLength(1); + expect(cacheEntries[0]?.files).toEqual([{ path: 'fresh.ts' }]); + }); +}); diff --git a/packages/ui/src/stores/useFileSearchStore.ts b/packages/ui/src/stores/useFileSearchStore.ts index 458a4c5c..65b01736 100644 --- a/packages/ui/src/stores/useFileSearchStore.ts +++ b/packages/ui/src/stores/useFileSearchStore.ts @@ -77,6 +77,10 @@ export const useFileSearchStore = create()( }) .then((files) => { set((state) => { + if (state.inFlight[key] !== searchPromise) { + return state; + } + const nextCache = { ...state.cache, [key]: { files, timestamp: Date.now() } }; const nextKeys = state.cacheKeys.filter((cacheKey) => cacheKey !== key); nextKeys.push(key); @@ -97,6 +101,10 @@ export const useFileSearchStore = create()( }) .finally(() => { set((state) => { + if (state.inFlight[key] !== searchPromise) { + return state; + } + const nextInFlight = { ...state.inFlight }; delete nextInFlight[key]; return { inFlight: nextInFlight };