Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion gui/src/pages/Startup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,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<StartupHealthData> => {
const secondaryGeneration = ++secondaryGenerationRef.current;
const keepSecondary = paintedRef.current;
// Keep prior notice/tray visible on revalidation; only reserve empty slots on first paint.
if (!keepSecondary) {
Expand Down Expand Up @@ -154,7 +160,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);
Expand Down
45 changes: 45 additions & 0 deletions gui/tests/startup-revisit-cache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>(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(<LanguageProvider><Startup apiBase={API_BASE} /></LanguageProvider>);
});
await act(async () => { await new Promise<void>(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<void>(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<void>(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();
});
Loading