From eeb40e253a5fc04fb340632146809ac41259a3a7 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 18:58:23 +0900 Subject: [PATCH] fix(gui): ignore stale Startup secondary responses --- gui/src/pages/Startup.tsx | 8 ++++- gui/tests/startup-revisit-cache.test.tsx | 45 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index a1ee56e50a..ec1cb1d61e 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -87,8 +87,14 @@ export default function Startup({ apiBase }: { apiBase: string }) { /** True while settings (runtime notice) are still in flight — reserves notice slot height. */ const [runtimeNoticePending, setRuntimeNoticePending] = useState(() => !cached?.data); const paintedRef = useRef(Boolean(cached?.data)); + const secondaryGenerationRef = useRef(0); + + useEffect(() => () => { + secondaryGenerationRef.current += 1; + }, [apiBase]); const fetchStartup = useCallback(async (signal: AbortSignal): Promise => { + const secondaryGeneration = ++secondaryGenerationRef.current; const keepSecondary = paintedRef.current; // Keep prior notice/tray visible on revalidation; only reserve empty slots on first paint. if (!keepSecondary) { @@ -130,7 +136,7 @@ export default function Startup({ apiBase }: { apiBase: string }) { // Health drives the main page, so publish it before the lower-priority settings/tray // requests finish. Their result updates the existing reserved slots independently. void Promise.all([settingsPromise, trayPromise]).then(([settings, trayResult]) => { - if (signal.aborted) return; + if (signal.aborted || secondaryGeneration !== secondaryGenerationRef.current) return; const nextTray = next.platform === "win32" ? trayResult.tray : null; if (next.platform === "win32") { setTray(nextTray); diff --git a/gui/tests/startup-revisit-cache.test.tsx b/gui/tests/startup-revisit-cache.test.tsx index 11a0ef7f6c..44674f170e 100644 --- a/gui/tests/startup-revisit-cache.test.tsx +++ b/gui/tests/startup-revisit-cache.test.tsx @@ -108,3 +108,48 @@ test("a revisit with session cache keeps Action required visible without a loadi await act(async () => { root.unmount(); }); container.remove(); }); + +test("a superseded settings response cannot overwrite newer Startup cache", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + let settingsCalls = 0; + let resolveStaleSettings!: (response: Response) => void; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/startup-health")) return Response.json(atRiskHealth()); + if (!url.includes("/api/settings")) return new Response(null, { status: 404 }); + settingsCalls += 1; + if (settingsCalls === 1) { + return await new Promise(resolve => { resolveStaleSettings = resolve; }); + } + return Response.json({ codexRuntime: { version: "fresh", newerAvailable: { version: "new" } } }); + }) as typeof fetch; + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 20)); }); + + const refresh = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.includes("Refresh")); + expect(refresh).toBeDefined(); + await act(async () => { refresh?.click(); }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 20)); }); + expect(settingsCalls).toBe(2); + expect(testWindow.sessionStorage.getItem(CACHE_KEY)).toContain("fresh"); + + await act(async () => { + resolveStaleSettings(Response.json({ codexRuntime: { version: "stale", newerAvailable: { version: "new" } } })); + await new Promise(r => testWindow.setTimeout(r, 20)); + }); + + expect(testWindow.sessionStorage.getItem(CACHE_KEY)).toContain("fresh"); + expect(testWindow.sessionStorage.getItem(CACHE_KEY)).not.toContain("stale"); + + await act(async () => { root.unmount(); }); + container.remove(); +});