From 9c7e922ebea660f9ea7c94e438416fa407983f5e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:17:44 +0200 Subject: [PATCH 1/8] fix(gui): add react-doctor shared foundations Add fetch/json helpers, client-resource hooks, and intl formatters, and keep the API auth token in memory instead of sessionStorage. --- gui/src/api.ts | 16 ++-- gui/src/client-resource.ts | 161 +++++++++++++++++++++++++++++++++++++ gui/src/fetch-json.ts | 27 +++++++ gui/src/intl-formatters.ts | 39 +++++++++ 4 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 gui/src/client-resource.ts create mode 100644 gui/src/fetch-json.ts create mode 100644 gui/src/intl-formatters.ts diff --git a/gui/src/api.ts b/gui/src/api.ts index 9bec2149d..86a752e1c 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,5 +1,3 @@ -const TOKEN_KEY = "opencodex-api-token"; - let installed = false; let promptInFlight: Promise | null = null; @@ -17,21 +15,19 @@ function needsApiAuth(input: RequestInfo | URL): boolean { return !!path && (path.startsWith("/api/") || path.startsWith("/v1/")); } +/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ +let memoryToken: string | null = null; + function readToken(): string | null { - try { - const token = sessionStorage.getItem(TOKEN_KEY)?.trim(); - return token || null; - } catch { - return null; - } + return memoryToken; } function storeToken(token: string): void { - try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* session storage may be disabled */ } + memoryToken = token; } function clearToken(): void { - try { sessionStorage.removeItem(TOKEN_KEY); } catch { /* session storage may be disabled */ } + memoryToken = null; } function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts new file mode 100644 index 000000000..dc9a63a23 --- /dev/null +++ b/gui/src/client-resource.ts @@ -0,0 +1,161 @@ +import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; + +export type ResourceSnapshot = { + data: T | undefined; + error: unknown; + loading: boolean; +}; + +type Store = { + snapshot: ResourceSnapshot; + listeners: Set<() => void>; + subscriberCount: number; + pollTimer: ReturnType | null; + inflight: AbortController | null; + generation: number; +}; + +const stores = new Map>(); + +const EMPTY_SNAPSHOT: ResourceSnapshot = { data: undefined, error: undefined, loading: false }; + +function getStore(key: string): Store { + let store = stores.get(key) as Store | undefined; + if (!store) { + store = { + snapshot: { data: undefined, error: undefined, loading: false }, + listeners: new Set(), + subscriberCount: 0, + pollTimer: null, + inflight: null, + generation: 0, + }; + stores.set(key, store); + } + return store; +} + +function emit(store: Store) { + for (const listener of store.listeners) listener(); +} + +async function runFetch( + store: Store, + fetcher: (signal: AbortSignal) => Promise, +) { + store.inflight?.abort(); + const controller = new AbortController(); + store.inflight = controller; + const gen = ++store.generation; + + if (!store.snapshot.data) { + store.snapshot = { ...store.snapshot, loading: true }; + emit(store); + } + + try { + const data = await fetcher(controller.signal); + if (gen !== store.generation || controller.signal.aborted) return; + store.snapshot = { data, error: undefined, loading: false }; + } catch (error) { + if (gen !== store.generation || controller.signal.aborted) return; + store.snapshot = { ...store.snapshot, error, loading: false }; + } finally { + if (store.inflight === controller) store.inflight = null; + emit(store); + } +} + +function subscribeResource( + key: string, + fetcher: (signal: AbortSignal) => Promise, + pollMs: number | undefined, + onStoreChange: () => void, +) { + const store = getStore(key); + store.listeners.add(onStoreChange); + store.subscriberCount++; + + if (store.subscriberCount === 1) { + void runFetch(store, fetcher); + if (pollMs && pollMs > 0) { + store.pollTimer = setInterval(() => void runFetch(store, fetcher), pollMs); + } + } + + return () => { + store.listeners.delete(onStoreChange); + store.subscriberCount--; + if (store.subscriberCount === 0) { + store.inflight?.abort(); + store.inflight = null; + if (store.pollTimer !== null) { + clearInterval(store.pollTimer); + store.pollTimer = null; + } + } + }; +} + +/** Module-level fetch cache with useSyncExternalStore subscriptions (no fetch in useEffect). */ +export function useClientResource( + key: string, + fetcher: (signal: AbortSignal) => Promise, + options?: { pollMs?: number; enabled?: boolean }, +): ResourceSnapshot & { refresh: () => void } { + const enabled = options?.enabled !== false; + const pollMs = options?.pollMs; + const fetcherRef = useRef(fetcher); + // Sync latest fetcher every commit. No dep array on purpose: inline fetchers are + // reallocated every render; listing them would re-subscribe forever. + useLayoutEffect(() => { + fetcherRef.current = fetcher; + }); + + const stableFetcher = useCallback( + (signal: AbortSignal) => fetcherRef.current(signal), + [], + ); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!enabled) return () => {}; + return subscribeResource(key, stableFetcher, pollMs, onStoreChange); + }, + [key, stableFetcher, pollMs, enabled], + ); + + const getSnapshot = useCallback((): ResourceSnapshot => { + if (!enabled) return EMPTY_SNAPSHOT; + return getStore(key).snapshot; + }, [key, enabled]); + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const refresh = useCallback(() => { + if (!enabled) return; + void runFetch(getStore(key), stableFetcher); + }, [key, stableFetcher, enabled]); + + return { ...snapshot, refresh }; +} + +/** Stable loader via explicit deps — avoids react-doctor fresh-deps on inline fetchers. */ +export function useKeyedClientResource( + key: string, + _deps: readonly unknown[], + load: (signal: AbortSignal) => Promise, + options?: { pollMs?: number; enabled?: boolean }, +): ResourceSnapshot & { refresh: () => void } { + // `useClientResource` keeps the latest `load` via layout-effect ref sync; `_deps` is + // retained so call sites can document intentional invalidation keys without + // wrapping every loader in useCallback. + void _deps; + return useClientResource(key, load, options); +} + +export function setClientResourceData(key: string, data: T) { + const store = getStore(key); + store.snapshot = { data, error: undefined, loading: false }; + emit(store); +} diff --git a/gui/src/fetch-json.ts b/gui/src/fetch-json.ts new file mode 100644 index 000000000..82b007f92 --- /dev/null +++ b/gui/src/fetch-json.ts @@ -0,0 +1,27 @@ +/** + * Helpers that check Response status before consuming the body. + * Satisfies react-doctor `no-fetch-response-used-without-status-check` + * (`fetch` resolves on HTTP 4xx/5xx). + */ + +export async function readJsonOrThrow( + res: Response, + fallbackMessage = `HTTP ${res.status}`, +): Promise { + if (!res.ok) { + let message = fallbackMessage; + try { + const errBody = await res.json() as { error?: unknown }; + if (typeof errBody?.error === "string" && errBody.error) message = errBody.error; + } catch { + // non-JSON error bodies keep the fallback message + } + throw new Error(message); + } + return res.json() as Promise; +} + +export async function readJsonIfOk(res: Response): Promise { + if (!res.ok) return null; + return res.json() as Promise; +} diff --git a/gui/src/intl-formatters.ts b/gui/src/intl-formatters.ts new file mode 100644 index 000000000..0b765cdaf --- /dev/null +++ b/gui/src/intl-formatters.ts @@ -0,0 +1,39 @@ +/** Cached Intl formatters — avoids reconstructing on every render/call. */ + +const numberFormatters = new Map(); + +function cacheKey(locale: string | undefined, options: object): string { + return `${locale ?? ""}\0${JSON.stringify(options)}`; +} + +export function cachedNumberFormat( + locale: string | undefined, + options?: Intl.NumberFormatOptions, +): Intl.NumberFormat { + const key = cacheKey(locale, options ?? {}); + let fmt = numberFormatters.get(key); + if (!fmt) { + fmt = new Intl.NumberFormat(locale, options); + numberFormatters.set(key, fmt); + } + return fmt; +} + +const CREDIT_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", +}); + +export function formatCreditDate(iso: string): string { + return CREDIT_DATE_FORMAT.format(new Date(iso)); +} + +/** Format a USD cost estimate for display. Returns "—" when unavailable. */ +export function formatEstimatedUsdValue(value: number, locale?: string): string { + if (!Number.isFinite(value) || value < 0) return "\u2014"; + return `~$${cachedNumberFormat(locale, { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value)}`; +} From f09ae7f368aeec020d7a3d1ba835e7d40f93db27 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:57:19 +0200 Subject: [PATCH 2/8] fix(gui): harden client-resource and fetch-json for review findings Honor keyed deps invalidation, skip overlapping poll ticks, preserve falsy cache hits, abort in-flight fetches on imperative writes, recompute poll intervals across subscribers, evict idle stores, and tolerate empty/204 JSON bodies. --- gui/src/client-resource.ts | 99 +++++++++++++++++++++++++++++++------- gui/src/fetch-json.ts | 11 ++++- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index dc9a63a23..2128c019e 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -9,12 +9,20 @@ export type ResourceSnapshot = { type Store = { snapshot: ResourceSnapshot; listeners: Set<() => void>; + /** listener → requested poll interval (undefined = no poll from that subscriber) */ + pollByListener: Map<() => void, number | undefined>; subscriberCount: number; pollTimer: ReturnType | null; inflight: AbortController | null; generation: number; + /** Latest fetcher for poll ticks (updated on each subscribe). */ + fetcher: ((signal: AbortSignal) => Promise) | null; }; +/** + * Module cache keyed by string. Call sites must not reuse the same key for + * different resource types (no runtime check — keys are an API contract). + */ const stores = new Map>(); const EMPTY_SNAPSHOT: ResourceSnapshot = { data: undefined, error: undefined, loading: false }; @@ -25,10 +33,12 @@ function getStore(key: string): Store { store = { snapshot: { data: undefined, error: undefined, loading: false }, listeners: new Set(), + pollByListener: new Map(), subscriberCount: 0, pollTimer: null, inflight: null, generation: 0, + fetcher: null, }; stores.set(key, store); } @@ -39,16 +49,45 @@ function emit(store: Store) { for (const listener of store.listeners) listener(); } +function clearPollTimer(store: Store) { + if (store.pollTimer !== null) { + clearInterval(store.pollTimer); + store.pollTimer = null; + } +} + +/** Honor the most aggressive (smallest positive) poll interval among subscribers. */ +function recomputePoll(store: Store) { + clearPollTimer(store); + let pollMs: number | undefined; + for (const ms of store.pollByListener.values()) { + if (typeof ms === "number" && ms > 0) { + pollMs = pollMs === undefined ? ms : Math.min(pollMs, ms); + } + } + if (pollMs === undefined || !store.fetcher) return; + const fetcher = store.fetcher; + store.pollTimer = setInterval(() => { + // Skip ticks while a request is in flight so slow polls can finish. + void runFetch(store, fetcher, { replaceInflight: false }); + }, pollMs); +} + async function runFetch( store: Store, fetcher: (signal: AbortSignal) => Promise, + options?: { replaceInflight?: boolean }, ) { - store.inflight?.abort(); + const replaceInflight = options?.replaceInflight !== false; + if (store.inflight && !replaceInflight) return; + + if (replaceInflight) store.inflight?.abort(); const controller = new AbortController(); store.inflight = controller; const gen = ++store.generation; - if (!store.snapshot.data) { + // Only treat missing cache as "empty" — falsy values (false/0/null/"") are valid data. + if (store.snapshot.data === undefined) { store.snapshot = { ...store.snapshot, loading: true }; emit(store); } @@ -73,27 +112,28 @@ function subscribeResource( onStoreChange: () => void, ) { const store = getStore(key); + store.fetcher = fetcher; store.listeners.add(onStoreChange); + store.pollByListener.set(onStoreChange, pollMs); store.subscriberCount++; if (store.subscriberCount === 1) { - void runFetch(store, fetcher); - if (pollMs && pollMs > 0) { - store.pollTimer = setInterval(() => void runFetch(store, fetcher), pollMs); - } + void runFetch(store, fetcher, { replaceInflight: true }); } + recomputePoll(store); return () => { store.listeners.delete(onStoreChange); + store.pollByListener.delete(onStoreChange); store.subscriberCount--; if (store.subscriberCount === 0) { store.inflight?.abort(); store.inflight = null; - if (store.pollTimer !== null) { - clearInterval(store.pollTimer); - store.pollTimer = null; - } + clearPollTimer(store); + stores.delete(key); + return; } + recomputePoll(store); }; } @@ -134,28 +174,51 @@ export function useClientResource( const refresh = useCallback(() => { if (!enabled) return; - void runFetch(getStore(key), stableFetcher); + void runFetch(getStore(key), stableFetcher, { replaceInflight: true }); }, [key, stableFetcher, enabled]); return { ...snapshot, refresh }; } -/** Stable loader via explicit deps — avoids react-doctor fresh-deps on inline fetchers. */ +function depsChanged(prev: readonly unknown[] | null, next: readonly unknown[]): boolean { + if (prev === null) return false; + if (prev.length !== next.length) return true; + for (let i = 0; i < prev.length; i++) { + if (!Object.is(prev[i], next[i])) return true; + } + return false; +} + +/** + * Like `useClientResource`, but refetches when `deps` change (element-wise + * `Object.is`), even if the cache `key` stays the same. Callers may allocate a + * fresh deps array each render — identity of the array is ignored. + */ export function useKeyedClientResource( key: string, - _deps: readonly unknown[], + deps: readonly unknown[], load: (signal: AbortSignal) => Promise, options?: { pollMs?: number; enabled?: boolean }, ): ResourceSnapshot & { refresh: () => void } { - // `useClientResource` keeps the latest `load` via layout-effect ref sync; `_deps` is - // retained so call sites can document intentional invalidation keys without - // wrapping every loader in useCallback. - void _deps; - return useClientResource(key, load, options); + const resource = useClientResource(key, load, options); + const prevDepsRef = useRef(null); + + useLayoutEffect(() => { + const prev = prevDepsRef.current; + prevDepsRef.current = deps; + if (!depsChanged(prev, deps)) return; + resource.refresh(); + }); + + return resource; } +/** Publish data for a key and invalidate any in-flight fetch so it cannot stomp this write. */ export function setClientResourceData(key: string, data: T) { const store = getStore(key); + store.inflight?.abort(); + store.inflight = null; + store.generation++; store.snapshot = { data, error: undefined, loading: false }; emit(store); } diff --git a/gui/src/fetch-json.ts b/gui/src/fetch-json.ts index 82b007f92..bdcc238cb 100644 --- a/gui/src/fetch-json.ts +++ b/gui/src/fetch-json.ts @@ -4,6 +4,13 @@ * (`fetch` resolves on HTTP 4xx/5xx). */ +async function readJsonBody(res: Response): Promise { + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!text.trim()) return undefined as T; + return JSON.parse(text) as T; +} + export async function readJsonOrThrow( res: Response, fallbackMessage = `HTTP ${res.status}`, @@ -18,10 +25,10 @@ export async function readJsonOrThrow( } throw new Error(message); } - return res.json() as Promise; + return readJsonBody(res); } export async function readJsonIfOk(res: Response): Promise { if (!res.ok) return null; - return res.json() as Promise; + return readJsonBody(res); } From 138751f78b13259ce6e7dad54b6909daca85e794 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:10:56 +0200 Subject: [PATCH 3/8] fix(gui): wipe legacy API token storage and per-subscriber poll fetchers Delete leftover sessionStorage opencodex-api-token on auth install without reading it, and poll with a remaining subscriber's loader after others unmount. --- gui/src/api.ts | 20 +++++ gui/src/client-resource.ts | 28 +++++-- gui/tests/api-auth-memory.test.ts | 67 +++++++++++++++++ gui/tests/client-resource-poll.test.tsx | 98 +++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 gui/tests/api-auth-memory.test.ts create mode 100644 gui/tests/client-resource-poll.test.tsx diff --git a/gui/src/api.ts b/gui/src/api.ts index 86a752e1c..fc85b27bb 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -15,6 +15,9 @@ function needsApiAuth(input: RequestInfo | URL): boolean { return !!path && (path.startsWith("/api/") || path.startsWith("/v1/")); } +/** Legacy sessionStorage key from pre-memory auth — wiped once on install, never read. */ +const LEGACY_TOKEN_KEY = "opencodex-api-token"; + /** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ let memoryToken: string | null = null; @@ -30,6 +33,14 @@ function clearToken(): void { memoryToken = null; } +function clearLegacySessionToken(): void { + try { + sessionStorage.removeItem(LEGACY_TOKEN_KEY); + } catch { + /* session storage may be disabled */ + } +} + function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); headers.set("X-OpenCodex-API-Key", token); @@ -48,6 +59,8 @@ async function promptForToken(): Promise { export function installApiAuthFetch(): void { if (installed) return; installed = true; + // Drop any leftover XSS-readable token; new tokens stay memory-only (no read/migrate). + clearLegacySessionToken(); const originalFetch = window.fetch.bind(window); window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!needsApiAuth(input)) return originalFetch(input, init); @@ -68,3 +81,10 @@ export function installApiAuthFetch(): void { return retry; }; } + +/** Test-only: allow a fresh `installApiAuthFetch()` in the same module instance. */ +export function resetApiAuthFetchForTests(): void { + installed = false; + memoryToken = null; + promptInFlight = null; +} diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 2128c019e..fa72a127a 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -11,12 +11,12 @@ type Store = { listeners: Set<() => void>; /** listener → requested poll interval (undefined = no poll from that subscriber) */ pollByListener: Map<() => void, number | undefined>; + /** listener → fetcher owned by that subscriber */ + fetcherByListener: Map<() => void, (signal: AbortSignal) => Promise>; subscriberCount: number; pollTimer: ReturnType | null; inflight: AbortController | null; generation: number; - /** Latest fetcher for poll ticks (updated on each subscribe). */ - fetcher: ((signal: AbortSignal) => Promise) | null; }; /** @@ -34,11 +34,11 @@ function getStore(key: string): Store { snapshot: { data: undefined, error: undefined, loading: false }, listeners: new Set(), pollByListener: new Map(), + fetcherByListener: new Map(), subscriberCount: 0, pollTimer: null, inflight: null, generation: 0, - fetcher: null, }; stores.set(key, store); } @@ -56,6 +56,20 @@ function clearPollTimer(store: Store) { } } +/** Prefer a polling subscriber's fetcher; otherwise any remaining subscriber. */ +function pickFetcher( + store: Store, +): ((signal: AbortSignal) => Promise) | null { + for (const [listener, ms] of store.pollByListener) { + if (typeof ms === "number" && ms > 0) { + const fetcher = store.fetcherByListener.get(listener); + if (fetcher) return fetcher; + } + } + for (const fetcher of store.fetcherByListener.values()) return fetcher; + return null; +} + /** Honor the most aggressive (smallest positive) poll interval among subscribers. */ function recomputePoll(store: Store) { clearPollTimer(store); @@ -65,9 +79,10 @@ function recomputePoll(store: Store) { pollMs = pollMs === undefined ? ms : Math.min(pollMs, ms); } } - if (pollMs === undefined || !store.fetcher) return; - const fetcher = store.fetcher; + if (pollMs === undefined) return; store.pollTimer = setInterval(() => { + const fetcher = pickFetcher(store); + if (!fetcher) return; // Skip ticks while a request is in flight so slow polls can finish. void runFetch(store, fetcher, { replaceInflight: false }); }, pollMs); @@ -112,9 +127,9 @@ function subscribeResource( onStoreChange: () => void, ) { const store = getStore(key); - store.fetcher = fetcher; store.listeners.add(onStoreChange); store.pollByListener.set(onStoreChange, pollMs); + store.fetcherByListener.set(onStoreChange, fetcher); store.subscriberCount++; if (store.subscriberCount === 1) { @@ -125,6 +140,7 @@ function subscribeResource( return () => { store.listeners.delete(onStoreChange); store.pollByListener.delete(onStoreChange); + store.fetcherByListener.delete(onStoreChange); store.subscriberCount--; if (store.subscriberCount === 0) { store.inflight?.abort(); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts new file mode 100644 index 000000000..598089611 --- /dev/null +++ b/gui/tests/api-auth-memory.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; + +const LEGACY_TOKEN_KEY = "opencodex-api-token"; +const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let originalPrompt: typeof window.prompt; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, + }); + originalPrompt = window.prompt; + resetApiAuthFetchForTests(); + sessionStorage.clear(); +}); + +afterEach(() => { + window.prompt = originalPrompt; + resetApiAuthFetchForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("installApiAuthFetch deletes legacy sessionStorage token without reading it", () => { + sessionStorage.setItem(LEGACY_TOKEN_KEY, "legacy-secret"); + installApiAuthFetch(); + expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); +}); + +test("prompted API tokens stay memory-only and are not written to sessionStorage", async () => { + sessionStorage.setItem(LEGACY_TOKEN_KEY, "legacy-secret"); + + let authorized = false; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key") === "fresh-token") { + authorized = true; + return new Response("{}", { status: 200 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + Object.defineProperty(window, "fetch", { configurable: true, value: mockFetch }); + window.prompt = () => "fresh-token"; + + installApiAuthFetch(); + expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); + // installApiAuthFetch replaces window.fetch — keep globalThis in sync for bare `fetch()`. + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); + + const res = await fetch("/api/config"); + expect(res.status).toBe(200); + expect(authorized).toBe(true); + expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); + expect(sessionStorage.length).toBe(0); +}); diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx new file mode 100644 index 000000000..024b209e1 --- /dev/null +++ b/gui/tests/client-resource-poll.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import { useClientResource } from "../src/client-resource"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("after the latest subscriber unmounts, polling continues with the surviving loader", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const calls: string[] = []; + const KEY = `poll-survivors-${Date.now()}`; + + function Subscriber({ + label, + pollMs, + }: { + label: string; + pollMs: number; + }) { + useClientResource( + KEY, + async () => { + calls.push(label); + return label; + }, + { pollMs }, + ); + return ; + } + + function Harness() { + const [showLatest, setShowLatest] = useState(true); + useEffect(() => { + (window as unknown as { __dropLatest?: () => void }).__dropLatest = () => setShowLatest(false); + }, []); + return ( + <> + + {showLatest ? : null} + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + // Initial subscribe fetch(es) from both subscribers sharing one store (first only fetches). + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + expect(calls.length).toBeGreaterThanOrEqual(1); + const beforeUnmount = calls.length; + + await act(async () => { + (window as unknown as { __dropLatest: () => void }).__dropLatest(); + }); + + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 130)); + }); + + const afterUnmount = calls.slice(beforeUnmount); + expect(afterUnmount.length).toBeGreaterThan(0); + expect(afterUnmount.every((label) => label === "survivor")).toBe(true); + expect(afterUnmount.some((label) => label === "latest")).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); From 7b0bcda79b5499ac3360fed4b2cd3bc26a36f344 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:18:46 +0200 Subject: [PATCH 4/8] fix(gui): abort in-flight client-resource fetches owned by unsubscribers Track the subscriber that started each request and invalidate it on unmount so a late resolve cannot overwrite shared snapshot data for remaining subscribers. --- gui/src/client-resource.ts | 49 +++++++++++---- gui/tests/client-resource-poll.test.tsx | 83 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index fa72a127a..6e22bfe9c 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -16,6 +16,8 @@ type Store = { subscriberCount: number; pollTimer: ReturnType | null; inflight: AbortController | null; + /** Subscriber that started the current in-flight request (if any). */ + inflightOwner: (() => void) | null; generation: number; }; @@ -38,6 +40,7 @@ function getStore(key: string): Store { subscriberCount: 0, pollTimer: null, inflight: null, + inflightOwner: null, generation: 0, }; stores.set(key, store); @@ -57,16 +60,18 @@ function clearPollTimer(store: Store) { } /** Prefer a polling subscriber's fetcher; otherwise any remaining subscriber. */ -function pickFetcher( +function pickFetcherEntry( store: Store, -): ((signal: AbortSignal) => Promise) | null { +): { owner: () => void; fetcher: (signal: AbortSignal) => Promise } | null { for (const [listener, ms] of store.pollByListener) { if (typeof ms === "number" && ms > 0) { const fetcher = store.fetcherByListener.get(listener); - if (fetcher) return fetcher; + if (fetcher) return { owner: listener, fetcher }; } } - for (const fetcher of store.fetcherByListener.values()) return fetcher; + for (const [listener, fetcher] of store.fetcherByListener) { + return { owner: listener, fetcher }; + } return null; } @@ -81,17 +86,17 @@ function recomputePoll(store: Store) { } if (pollMs === undefined) return; store.pollTimer = setInterval(() => { - const fetcher = pickFetcher(store); - if (!fetcher) return; + const entry = pickFetcherEntry(store); + if (!entry) return; // Skip ticks while a request is in flight so slow polls can finish. - void runFetch(store, fetcher, { replaceInflight: false }); + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner }); }, pollMs); } async function runFetch( store: Store, fetcher: (signal: AbortSignal) => Promise, - options?: { replaceInflight?: boolean }, + options?: { replaceInflight?: boolean; owner?: (() => void) | null }, ) { const replaceInflight = options?.replaceInflight !== false; if (store.inflight && !replaceInflight) return; @@ -99,6 +104,7 @@ async function runFetch( if (replaceInflight) store.inflight?.abort(); const controller = new AbortController(); store.inflight = controller; + store.inflightOwner = options?.owner ?? null; const gen = ++store.generation; // Only treat missing cache as "empty" — falsy values (false/0/null/"") are valid data. @@ -115,11 +121,22 @@ async function runFetch( if (gen !== store.generation || controller.signal.aborted) return; store.snapshot = { ...store.snapshot, error, loading: false }; } finally { - if (store.inflight === controller) store.inflight = null; + if (store.inflight === controller) { + store.inflight = null; + store.inflightOwner = null; + } emit(store); } } +function abortInflightOwnedBy(store: Store, owner: () => void) { + if (store.inflightOwner !== owner) return; + store.inflight?.abort(); + store.inflight = null; + store.inflightOwner = null; + store.generation++; +} + function subscribeResource( key: string, fetcher: (signal: AbortSignal) => Promise, @@ -133,7 +150,7 @@ function subscribeResource( store.subscriberCount++; if (store.subscriberCount === 1) { - void runFetch(store, fetcher, { replaceInflight: true }); + void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange }); } recomputePoll(store); @@ -142,9 +159,12 @@ function subscribeResource( store.pollByListener.delete(onStoreChange); store.fetcherByListener.delete(onStoreChange); store.subscriberCount--; + // Drop this subscriber's in-flight work so a late resolve cannot stomp shared data. + abortInflightOwnedBy(store, onStoreChange); if (store.subscriberCount === 0) { store.inflight?.abort(); store.inflight = null; + store.inflightOwner = null; clearPollTimer(store); stores.delete(key); return; @@ -173,9 +193,12 @@ export function useClientResource( [], ); + const listenerRef = useRef<(() => void) | null>(null); + const subscribe = useCallback( (onStoreChange: () => void) => { if (!enabled) return () => {}; + listenerRef.current = onStoreChange; return subscribeResource(key, stableFetcher, pollMs, onStoreChange); }, [key, stableFetcher, pollMs, enabled], @@ -190,7 +213,10 @@ export function useClientResource( const refresh = useCallback(() => { if (!enabled) return; - void runFetch(getStore(key), stableFetcher, { replaceInflight: true }); + void runFetch(getStore(key), stableFetcher, { + replaceInflight: true, + owner: listenerRef.current, + }); }, [key, stableFetcher, enabled]); return { ...snapshot, refresh }; @@ -234,6 +260,7 @@ export function setClientResourceData(key: string, data: T) { const store = getStore(key); store.inflight?.abort(); store.inflight = null; + store.inflightOwner = null; store.generation++; store.snapshot = { data, error: undefined, loading: false }; emit(store); diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx index 024b209e1..61a344d07 100644 --- a/gui/tests/client-resource-poll.test.tsx +++ b/gui/tests/client-resource-poll.test.tsx @@ -96,3 +96,86 @@ test("after the latest subscriber unmounts, polling continues with the surviving }); container.remove(); }); + +test("unmounting the subscriber that owns an in-flight request must not overwrite remaining data", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const KEY = `inflight-owner-${Date.now()}`; + let resolveB!: (value: string) => void; + const deferredB = new Promise((resolve) => { + resolveB = resolve; + }); + + type HarnessApi = { + dropB: () => void; + refreshB: () => void; + readA: () => string | undefined; + }; + const api: HarnessApi = { + dropB: () => {}, + refreshB: () => {}, + readA: () => undefined, + }; + + function SubscriberA() { + const resource = useClientResource(KEY, async () => "from-A"); + useEffect(() => { + api.readA = () => resource.data; + }, [resource.data]); + return ; + } + + function SubscriberB() { + const resource = useClientResource(KEY, async () => deferredB); + useEffect(() => { + api.refreshB = () => resource.refresh(); + }, [resource.refresh]); + return ; + } + + function Harness() { + const [showB, setShowB] = useState(true); + useEffect(() => { + api.dropB = () => setShowB(false); + }, []); + return ( + <> + + {showB ? : null} + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 20)); + }); + expect(api.readA()).toBe("from-A"); + + await act(async () => { + api.refreshB(); + }); + + await act(async () => { + api.dropB(); + }); + + await act(async () => { + resolveB("from-B"); + await new Promise((resolve) => testWindow.setTimeout(resolve, 20)); + }); + + expect(api.readA()).toBe("from-A"); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); From 971e0564c5301f00626500b0b322c5bc87d5e14e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:25:33 +0200 Subject: [PATCH 5/8] fix(gui): replace aborted client-resource fetches when subscribers remain When the in-flight owner unsubscribes, start a replacement fetch from a remaining subscriber so the shared snapshot cannot stay stuck loading. --- gui/src/client-resource.ts | 14 ++++- gui/tests/client-resource-poll.test.tsx | 80 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 6e22bfe9c..7a594df86 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -129,12 +129,13 @@ async function runFetch( } } -function abortInflightOwnedBy(store: Store, owner: () => void) { - if (store.inflightOwner !== owner) return; +function abortInflightOwnedBy(store: Store, owner: () => void): boolean { + if (store.inflightOwner !== owner) return false; store.inflight?.abort(); store.inflight = null; store.inflightOwner = null; store.generation++; + return true; } function subscribeResource( @@ -160,7 +161,7 @@ function subscribeResource( store.fetcherByListener.delete(onStoreChange); store.subscriberCount--; // Drop this subscriber's in-flight work so a late resolve cannot stomp shared data. - abortInflightOwnedBy(store, onStoreChange); + const abortedOwned = abortInflightOwnedBy(store, onStoreChange); if (store.subscriberCount === 0) { store.inflight?.abort(); store.inflight = null; @@ -169,6 +170,13 @@ function subscribeResource( stores.delete(key); return; } + // Replace aborted work immediately so the shared snapshot cannot stay stuck loading. + if (abortedOwned) { + const entry = pickFetcherEntry(store); + if (entry) { + void runFetch(store, entry.fetcher, { replaceInflight: true, owner: entry.owner }); + } + } recomputePoll(store); }; } diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx index 61a344d07..0e22154d6 100644 --- a/gui/tests/client-resource-poll.test.tsx +++ b/gui/tests/client-resource-poll.test.tsx @@ -179,3 +179,83 @@ test("unmounting the subscriber that owns an in-flight request must not overwrit }); container.remove(); }); + +test("when the in-flight owner unmounts with no cache, a remaining subscriber replaces the fetch", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const KEY = `replace-inflight-${Date.now()}`; + const deferredB = new Promise(() => { + /* intentionally never resolves — owner unmount must replace it */ + }); + + type HarnessApi = { + mountA: () => void; + dropB: () => void; + read: () => { data: string | undefined; loading: boolean }; + }; + const api: HarnessApi = { + mountA: () => {}, + dropB: () => {}, + read: () => ({ data: undefined, loading: false }), + }; + + function SubscriberA() { + const resource = useClientResource(KEY, async () => "from-A"); + useEffect(() => { + api.read = () => ({ data: resource.data, loading: resource.loading }); + }, [resource.data, resource.loading]); + return ; + } + + function SubscriberB() { + useClientResource(KEY, async () => deferredB); + return ; + } + + function Harness() { + const [showA, setShowA] = useState(false); + const [showB, setShowB] = useState(true); + useEffect(() => { + api.mountA = () => setShowA(true); + api.dropB = () => setShowB(false); + }, []); + return ( + <> + {showB ? : null} + {showA ? : null} + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + // B is sole subscriber with a deferred request → shared snapshot is loading with no data. + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + + await act(async () => { + api.mountA(); + }); + + await act(async () => { + api.dropB(); + }); + + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 30)); + }); + + expect(api.read()).toEqual({ data: "from-A", loading: false }); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); From 3566908882b810019ab8e4ef83cb3bb26f27dd75 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:30:35 +0200 Subject: [PATCH 6/8] fix(gui): address remaining CodeRabbit findings on foundations Keep poll timers stable under subscriber churn, force loading on keyed deps changes, expose undefined in fetch-json types, and tighten auth/resource tests. --- gui/src/client-resource.ts | 28 +++++--- gui/src/fetch-json.ts | 11 +-- gui/tests/api-auth-memory.test.ts | 17 ++++- gui/tests/client-resource-poll.test.tsx | 89 ++++++++++++++++++++----- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 7a594df86..a054ba69b 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -15,6 +15,8 @@ type Store = { fetcherByListener: Map<() => void, (signal: AbortSignal) => Promise>; subscriberCount: number; pollTimer: ReturnType | null; + /** Currently scheduled poll interval; avoids resetting the countdown on churn. */ + pollIntervalMs: number | undefined; inflight: AbortController | null; /** Subscriber that started the current in-flight request (if any). */ inflightOwner: (() => void) | null; @@ -39,6 +41,7 @@ function getStore(key: string): Store { fetcherByListener: new Map(), subscriberCount: 0, pollTimer: null, + pollIntervalMs: undefined, inflight: null, inflightOwner: null, generation: 0, @@ -57,6 +60,7 @@ function clearPollTimer(store: Store) { clearInterval(store.pollTimer); store.pollTimer = null; } + store.pollIntervalMs = undefined; } /** Prefer a polling subscriber's fetcher; otherwise any remaining subscriber. */ @@ -77,13 +81,18 @@ function pickFetcherEntry( /** Honor the most aggressive (smallest positive) poll interval among subscribers. */ function recomputePoll(store: Store) { - clearPollTimer(store); let pollMs: number | undefined; for (const ms of store.pollByListener.values()) { if (typeof ms === "number" && ms > 0) { pollMs = pollMs === undefined ? ms : Math.min(pollMs, ms); } } + // Keep the existing countdown when the effective interval is unchanged. + if (pollMs === store.pollIntervalMs && (pollMs === undefined || store.pollTimer !== null)) { + return; + } + clearPollTimer(store); + store.pollIntervalMs = pollMs; if (pollMs === undefined) return; store.pollTimer = setInterval(() => { const entry = pickFetcherEntry(store); @@ -96,7 +105,7 @@ function recomputePoll(store: Store) { async function runFetch( store: Store, fetcher: (signal: AbortSignal) => Promise, - options?: { replaceInflight?: boolean; owner?: (() => void) | null }, + options?: { replaceInflight?: boolean; owner?: (() => void) | null; forceLoading?: boolean }, ) { const replaceInflight = options?.replaceInflight !== false; if (store.inflight && !replaceInflight) return; @@ -107,8 +116,8 @@ async function runFetch( store.inflightOwner = options?.owner ?? null; const gen = ++store.generation; - // Only treat missing cache as "empty" — falsy values (false/0/null/"") are valid data. - if (store.snapshot.data === undefined) { + // Falsy cached values stay visible during polls; forceLoading is for identity changes (deps). + if (store.snapshot.data === undefined || options?.forceLoading) { store.snapshot = { ...store.snapshot, loading: true }; emit(store); } @@ -186,7 +195,7 @@ export function useClientResource( key: string, fetcher: (signal: AbortSignal) => Promise, options?: { pollMs?: number; enabled?: boolean }, -): ResourceSnapshot & { refresh: () => void } { +): ResourceSnapshot & { refresh: (opts?: { forceLoading?: boolean }) => void } { const enabled = options?.enabled !== false; const pollMs = options?.pollMs; const fetcherRef = useRef(fetcher); @@ -219,11 +228,12 @@ export function useClientResource( const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - const refresh = useCallback(() => { + const refresh = useCallback((opts?: { forceLoading?: boolean }) => { if (!enabled) return; void runFetch(getStore(key), stableFetcher, { replaceInflight: true, owner: listenerRef.current, + forceLoading: opts?.forceLoading, }); }, [key, stableFetcher, enabled]); @@ -243,13 +253,15 @@ function depsChanged(prev: readonly unknown[] | null, next: readonly unknown[]): * Like `useClientResource`, but refetches when `deps` change (element-wise * `Object.is`), even if the cache `key` stays the same. Callers may allocate a * fresh deps array each render — identity of the array is ignored. + * Deps changes force `loading: true` while retaining previous data until the + * new response arrives (unlike quiet poll refreshes). */ export function useKeyedClientResource( key: string, deps: readonly unknown[], load: (signal: AbortSignal) => Promise, options?: { pollMs?: number; enabled?: boolean }, -): ResourceSnapshot & { refresh: () => void } { +): ResourceSnapshot & { refresh: (opts?: { forceLoading?: boolean }) => void } { const resource = useClientResource(key, load, options); const prevDepsRef = useRef(null); @@ -257,7 +269,7 @@ export function useKeyedClientResource( const prev = prevDepsRef.current; prevDepsRef.current = deps; if (!depsChanged(prev, deps)) return; - resource.refresh(); + resource.refresh({ forceLoading: true }); }); return resource; diff --git a/gui/src/fetch-json.ts b/gui/src/fetch-json.ts index bdcc238cb..a179ee718 100644 --- a/gui/src/fetch-json.ts +++ b/gui/src/fetch-json.ts @@ -4,17 +4,18 @@ * (`fetch` resolves on HTTP 4xx/5xx). */ -async function readJsonBody(res: Response): Promise { - if (res.status === 204) return undefined as T; +/** Parse an OK response body; 204 / empty bodies yield `undefined`. */ +async function readJsonBody(res: Response): Promise { + if (res.status === 204) return undefined; const text = await res.text(); - if (!text.trim()) return undefined as T; + if (!text.trim()) return undefined; return JSON.parse(text) as T; } export async function readJsonOrThrow( res: Response, fallbackMessage = `HTTP ${res.status}`, -): Promise { +): Promise { if (!res.ok) { let message = fallbackMessage; try { @@ -28,7 +29,7 @@ export async function readJsonOrThrow( return readJsonBody(res); } -export async function readJsonIfOk(res: Response): Promise { +export async function readJsonIfOk(res: Response): Promise { if (!res.ok) return null; return readJsonBody(res); } diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 598089611..4fd28967d 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -34,8 +34,21 @@ afterEach(() => { test("installApiAuthFetch deletes legacy sessionStorage token without reading it", () => { sessionStorage.setItem(LEGACY_TOKEN_KEY, "legacy-secret"); - installApiAuthFetch(); - expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); + let getItemCalls = 0; + const storage = sessionStorage; + const originalGetItem = storage.getItem.bind(storage); + storage.getItem = ((key: string) => { + getItemCalls += 1; + return originalGetItem(key); + }) as typeof storage.getItem; + + try { + installApiAuthFetch(); + expect(getItemCalls).toBe(0); + expect(originalGetItem(LEGACY_TOKEN_KEY)).toBeNull(); + } finally { + storage.getItem = originalGetItem; + } }); test("prompted API tokens stay memory-only and are not written to sessionStorage", async () => { diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx index 0e22154d6..b529409b4 100644 --- a/gui/tests/client-resource-poll.test.tsx +++ b/gui/tests/client-resource-poll.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act, useEffect, useState } from "react"; import type { Root } from "react-dom/client"; -import { useClientResource } from "../src/client-resource"; +import { useClientResource, useKeyedClientResource } from "../src/client-resource"; const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; @@ -26,6 +26,16 @@ afterEach(() => { } }); +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + } +} + test("after the latest subscriber unmounts, polling continues with the surviving loader", async () => { const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); @@ -72,19 +82,14 @@ test("after the latest subscriber unmounts, polling continues with the surviving }); // Initial subscribe fetch(es) from both subscribers sharing one store (first only fetches). - await act(async () => { - await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); - }); - expect(calls.length).toBeGreaterThanOrEqual(1); + await waitFor(() => calls.length >= 1); const beforeUnmount = calls.length; await act(async () => { (window as unknown as { __dropLatest: () => void }).__dropLatest(); }); - await act(async () => { - await new Promise((resolve) => testWindow.setTimeout(resolve, 130)); - }); + await waitFor(() => calls.length > beforeUnmount); const afterUnmount = calls.slice(beforeUnmount); expect(afterUnmount.length).toBeGreaterThan(0); @@ -154,10 +159,7 @@ test("unmounting the subscriber that owns an in-flight request must not overwrit root.render(); }); - await act(async () => { - await new Promise((resolve) => testWindow.setTimeout(resolve, 20)); - }); - expect(api.readA()).toBe("from-A"); + await waitFor(() => api.readA() === "from-A"); await act(async () => { api.refreshB(); @@ -169,9 +171,9 @@ test("unmounting the subscriber that owns an in-flight request must not overwrit await act(async () => { resolveB("from-B"); - await new Promise((resolve) => testWindow.setTimeout(resolve, 20)); }); - + // Settle so a buggy late write would have time to land. + await waitFor(() => api.readA() === "from-A"); expect(api.readA()).toBe("from-A"); await act(async () => { @@ -248,11 +250,66 @@ test("when the in-flight owner unmounts with no cache, a remaining subscriber re api.dropB(); }); + await waitFor(() => api.read().data === "from-A" && api.read().loading === false); + + expect(api.read()).toEqual({ data: "from-A", loading: false }); + await act(async () => { - await new Promise((resolve) => testWindow.setTimeout(resolve, 30)); + root.unmount(); }); + container.remove(); +}); - expect(api.read()).toEqual({ data: "from-A", loading: false }); +test("keyed deps changes force loading while retaining previous data until refetch completes", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const KEY = `keyed-deps-${Date.now()}`; + let resolveNext!: (value: string) => void; + let nextValue = new Promise((resolve) => { + resolveNext = resolve; + }); + + type Snap = { data: string | undefined; loading: boolean }; + let snap: Snap = { data: undefined, loading: false }; + let setFilter!: (value: string) => void; + + function Page() { + const [filter, setFilterState] = useState("one"); + setFilter = setFilterState; + const resource = useKeyedClientResource( + KEY, + [filter], + async () => { + if (filter === "one") return "data-one"; + return nextValue; + }, + ); + useEffect(() => { + snap = { data: resource.data, loading: resource.loading }; + }, [resource.data, resource.loading]); + return ; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + await waitFor(() => snap.data === "data-one" && snap.loading === false); + + await act(async () => { + setFilter("two"); + }); + + await waitFor(() => snap.loading === true && snap.data === "data-one"); + + await act(async () => { + resolveNext("data-two"); + }); + await waitFor(() => snap.data === "data-two" && snap.loading === false); await act(async () => { root.unmount(); From afc99ec6bf4025f51879448e23bc7cab2d967e61 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:39:05 +0200 Subject: [PATCH 7/8] fix(gui): scope API auth fetch to same-origin /api and /v1 Avoid attaching the memory API token or prompting on cross-origin absolute URLs that happen to use those path prefixes. --- gui/src/api.ts | 14 +++--- gui/tests/api-auth-memory.test.ts | 77 ++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index fc85b27bb..fd6a777bb 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,20 +1,18 @@ let installed = false; let promptInFlight: Promise | null = null; -function apiPath(input: RequestInfo | URL): string | null { +function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); - return new URL(raw, window.location.href).pathname; + const url = new URL(raw, window.location.href); + // Absolute cross-origin URLs must never get the local API token or 401 prompt. + if (url.origin !== window.location.origin) return false; + return url.pathname.startsWith("/api/") || url.pathname.startsWith("/v1/"); } catch { - return null; + return false; } } -function needsApiAuth(input: RequestInfo | URL): boolean { - const path = apiPath(input); - return !!path && (path.startsWith("/api/") || path.startsWith("/v1/")); -} - /** Legacy sessionStorage key from pre-memory auth — wiped once on install, never read. */ const LEGACY_TOKEN_KEY = "opencodex-api-token"; diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 4fd28967d..4d27d8600 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -32,6 +32,14 @@ afterEach(() => { } }); +async function installMockAuthFetch(handler: typeof fetch): Promise { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: handler }); + Object.defineProperty(window, "fetch", { configurable: true, value: handler }); + installApiAuthFetch(); + // installApiAuthFetch replaces window.fetch — keep globalThis in sync for bare `fetch()`. + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); +} + test("installApiAuthFetch deletes legacy sessionStorage token without reading it", () => { sessionStorage.setItem(LEGACY_TOKEN_KEY, "legacy-secret"); let getItemCalls = 0; @@ -63,14 +71,9 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage } return new Response("unauthorized", { status: 401 }); }) as typeof fetch; - Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); - Object.defineProperty(window, "fetch", { configurable: true, value: mockFetch }); window.prompt = () => "fresh-token"; - installApiAuthFetch(); - expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); - // installApiAuthFetch replaces window.fetch — keep globalThis in sync for bare `fetch()`. - Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); + await installMockAuthFetch(mockFetch); const res = await fetch("/api/config"); expect(res.status).toBe(200); @@ -78,3 +81,65 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); expect(sessionStorage.length).toBe(0); }); + +test("cross-origin /api/* requests do not receive the API key or token prompt", async () => { + let promptCalls = 0; + let phase: "seed" | "cross" = "seed"; + const seenHeaders: Array = []; + const stateful = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenHeaders.push(headers.get("X-OpenCodex-API-Key")); + if (phase === "seed") { + if (headers.get("X-OpenCodex-API-Key") === "local-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "local-token"; + }; + await installMockAuthFetch(stateful); + + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + + phase = "cross"; + const beforeCrossPrompts = promptCalls; + seenHeaders.length = 0; + const cross = await fetch("https://evil.example/api/config"); + expect(cross.status).toBe(401); + expect(seenHeaders).toEqual([null]); + expect(promptCalls).toBe(beforeCrossPrompts); +}); + +test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => { + let promptCalls = 0; + let phase: "seed" | "cross" = "seed"; + const seenHeaders: Array = []; + const stateful = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenHeaders.push(headers.get("X-OpenCodex-API-Key")); + if (phase === "seed") { + if (headers.get("X-OpenCodex-API-Key") === "local-token") return new Response("{}", { status: 200 }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "local-token"; + }; + await installMockAuthFetch(stateful); + + expect((await fetch("/v1/models")).status).toBe(200); + expect(promptCalls).toBe(1); + + phase = "cross"; + const beforeCrossPrompts = promptCalls; + seenHeaders.length = 0; + const cross = await fetch("https://evil.example/v1/models"); + expect(cross.status).toBe(401); + expect(seenHeaders).toEqual([null]); + expect(promptCalls).toBe(beforeCrossPrompts); +}); From 3616d2ae4a33dff18bc80bab5c97407cf8e1117f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:53:13 +0200 Subject: [PATCH 8/8] fix(gui): defer client-resource store eviction across resubscribe churn Keep cached data when React tears down and reattaches a single subscriber for pollMs or enabled changes; only delete the store on the next tick if it stays empty. --- gui/src/client-resource.ts | 26 ++++-- gui/tests/client-resource-poll.test.tsx | 104 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index a054ba69b..da5b2e667 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -147,6 +147,23 @@ function abortInflightOwnedBy(store: Store, owner: () => void): boolean { return true; } +/** + * Drop the module cache only after a macrotask so React's subscribe teardown + + * resubscribe (pollMs / enabled / key churn) can reattach in the same turn + * without wiping cached data. + */ +function scheduleStoreEviction(key: string, store: Store) { + clearPollTimer(store); + setTimeout(() => { + if (store.subscriberCount !== 0) return; + if (stores.get(key) !== store) return; + store.inflight?.abort(); + store.inflight = null; + store.inflightOwner = null; + stores.delete(key); + }, 0); +} + function subscribeResource( key: string, fetcher: (signal: AbortSignal) => Promise, @@ -159,7 +176,8 @@ function subscribeResource( store.fetcherByListener.set(onStoreChange, fetcher); store.subscriberCount++; - if (store.subscriberCount === 1) { + // Cold start only — keep cached data across transient 0→1 resubscribe gaps. + if (store.subscriberCount === 1 && store.snapshot.data === undefined) { void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange }); } recomputePoll(store); @@ -172,11 +190,7 @@ function subscribeResource( // Drop this subscriber's in-flight work so a late resolve cannot stomp shared data. const abortedOwned = abortInflightOwnedBy(store, onStoreChange); if (store.subscriberCount === 0) { - store.inflight?.abort(); - store.inflight = null; - store.inflightOwner = null; - clearPollTimer(store); - stores.delete(key); + scheduleStoreEviction(key, store); return; } // Replace aborted work immediately so the shared snapshot cannot stay stuck loading. diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx index b529409b4..6a16b0962 100644 --- a/gui/tests/client-resource-poll.test.tsx +++ b/gui/tests/client-resource-poll.test.tsx @@ -316,3 +316,107 @@ test("keyed deps changes force loading while retaining previous data until refet }); container.remove(); }); + +test("changing pollMs keeps cached data without a cold refetch", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const KEY = `pollms-churn-${Date.now()}`; + let fetches = 0; + type Snap = { data: string | undefined; loading: boolean }; + let snap: Snap = { data: undefined, loading: false }; + let setPollMs!: (value: number) => void; + + function Page() { + const [pollMs, setPollMsState] = useState(40); + setPollMs = setPollMsState; + const resource = useClientResource( + KEY, + async () => { + fetches += 1; + return "cached"; + }, + { pollMs }, + ); + useEffect(() => { + snap = { data: resource.data, loading: resource.loading }; + }, [resource.data, resource.loading]); + return ; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + await waitFor(() => snap.data === "cached" && snap.loading === false); + expect(fetches).toBe(1); + + await act(async () => { + setPollMs(80); + }); + + // Subscribe identity churn must not wipe the store or force a cold fetch. + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 20)); + }); + expect(snap).toEqual({ data: "cached", loading: false }); + expect(fetches).toBe(1); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +test("store is evicted after the last subscriber leaves", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const KEY = `store-evict-${Date.now()}`; + let fetches = 0; + type Snap = { data: string | undefined }; + let snap: Snap = { data: undefined }; + + function Page() { + const resource = useClientResource(KEY, async () => { + fetches += 1; + return `v${fetches}`; + }); + useEffect(() => { + snap = { data: resource.data }; + }, [resource.data]); + return ; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => snap.data === "v1"); + expect(fetches).toBe(1); + + await act(async () => { + root.unmount(); + }); + // Flush deferred eviction. + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + }); + + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => snap.data === "v2"); + expect(fetches).toBe(2); + + await act(async () => { + root.unmount(); + }); + container.remove(); +});