From 3be19dfbd15acd28087ce98411be9e6abe5f07ec Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 12 Sep 2026 12:03:23 +0800 Subject: [PATCH] fix(models): invalidate stale requests when clearing cache --- src/stores/useModelsStore.ts | 11 ++++- tests/modelsStore.test.ts | 94 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 tests/modelsStore.test.ts diff --git a/src/stores/useModelsStore.ts b/src/stores/useModelsStore.ts index 66638398..153fded7 100644 --- a/src/stores/useModelsStore.ts +++ b/src/stores/useModelsStore.ts @@ -25,6 +25,8 @@ interface ModelsState { isCacheValid: (apiBase: string, apiKey?: string) => boolean; } +let modelsRequestToken = 0; + export const useModelsStore = create((set, get) => ({ models: [], loading: false, @@ -32,12 +34,13 @@ export const useModelsStore = create((set, get) => ({ cache: null, fetchModels: async (apiBase, apiKey, forceRefresh = false) => { + const requestId = (modelsRequestToken += 1); const { cache, isCacheValid } = get(); const apiKeyScope = apiKey?.trim() || ''; // 检查缓存 if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) { - set({ models: cache.data, error: null }); + set({ models: cache.data, loading: false, error: null }); return cache.data; } @@ -45,6 +48,8 @@ export const useModelsStore = create((set, get) => ({ try { const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined); + // 清空缓存或后续加载已接管状态时,旧请求仅向调用方返回,不再写入 store。 + if (requestId !== modelsRequestToken) return list; const now = Date.now(); set({ @@ -55,6 +60,7 @@ export const useModelsStore = create((set, get) => ({ return list; } catch (error: unknown) { + if (requestId !== modelsRequestToken) throw error; const message = error instanceof Error ? error.message @@ -71,7 +77,8 @@ export const useModelsStore = create((set, get) => ({ }, clearCache: () => { - set({ cache: null, models: [] }); + modelsRequestToken += 1; + set({ cache: null, models: [], loading: false, error: null }); }, isCacheValid: (apiBase, apiKey) => { diff --git a/tests/modelsStore.test.ts b/tests/modelsStore.test.ts new file mode 100644 index 00000000..6c394530 --- /dev/null +++ b/tests/modelsStore.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; +import { modelsApi } from '../src/services/api/models'; +import { useModelsStore } from '../src/stores/useModelsStore'; +import type { ModelInfo } from '../src/utils/models'; + +function deferred() { + let resolve!: (models: ModelInfo[]) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const originalFetch = modelsApi.fetchModels; +afterEach(() => { + modelsApi.fetchModels = originalFetch; + useModelsStore.getState().clearCache(); +}); + +describe('models cache request isolation', () => { + it('does not restore models or connection cache after clearing an in-flight request', async () => { + const request = deferred(); + spyOn(modelsApi, 'fetchModels').mockReturnValue(request.promise); + const pending = useModelsStore.getState().fetchModels('https://old.invalid'); + useModelsStore.getState().clearCache(); + expect(useModelsStore.getState().loading).toBe(false); + request.resolve([{ name: 'old' }]); + expect(await pending).toEqual([{ name: 'old' }]); + expect(useModelsStore.getState()).toMatchObject({ + models: [], + cache: null, + loading: false, + error: null, + }); + }); + + it('ignores stale failures without clearing a newer request loading state', async () => { + const old = deferred(); + const current = deferred(); + spyOn(modelsApi, 'fetchModels') + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + const pending = useModelsStore.getState().fetchModels('https://old.invalid'); + useModelsStore.getState().clearCache(); + const next = useModelsStore.getState().fetchModels('https://new.invalid'); + old.reject(new Error('old failure')); + await expect(pending).rejects.toThrow('old failure'); + expect(useModelsStore.getState()).toMatchObject({ loading: true, error: null }); + current.resolve([{ name: 'new' }]); + await next; + expect(useModelsStore.getState().cache?.apiBase).toBe('https://new.invalid'); + }); + + it('allows only the latest request to publish, even without an explicit clear', async () => { + const old = deferred(); + const current = deferred(); + spyOn(modelsApi, 'fetchModels') + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + const pending = useModelsStore.getState().fetchModels('https://old.invalid'); + const next = useModelsStore.getState().fetchModels('https://new.invalid'); + current.resolve([{ name: 'new' }]); + await next; + old.resolve([{ name: 'old' }]); + await pending; + expect(useModelsStore.getState().models).toEqual([{ name: 'new' }]); + }); + + it('a cache hit supersedes an in-flight refresh and resets loading', async () => { + const request = deferred(); + spyOn(modelsApi, 'fetchModels') + .mockResolvedValueOnce([{ name: 'cached' }]) + .mockReturnValueOnce(request.promise); + await useModelsStore.getState().fetchModels('https://cache.invalid', ' test-key '); + const pending = useModelsStore + .getState() + .fetchModels('https://cache.invalid', 'test-key', true); + await useModelsStore.getState().fetchModels('https://cache.invalid', 'test-key'); + expect(useModelsStore.getState().loading).toBe(false); + request.resolve([{ name: 'stale refresh' }]); + await pending; + expect(useModelsStore.getState().models).toEqual([{ name: 'cached' }]); + }); + + it('reports active failures, then clears error and loading on invalidation', async () => { + spyOn(modelsApi, 'fetchModels').mockRejectedValue(new Error('active failure')); + await expect(useModelsStore.getState().fetchModels('https://test.invalid')).rejects.toThrow(); + expect(useModelsStore.getState().error).toBe('active failure'); + useModelsStore.getState().clearCache(); + expect(useModelsStore.getState()).toMatchObject({ error: null, loading: false }); + }); +});