diff --git a/gui/src/api.ts b/gui/src/api.ts index 9bec2149d..fd6a777bb 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,37 +1,42 @@ -const TOKEN_KEY = "opencodex-api-token"; - 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"; + +/** 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 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] { @@ -52,6 +57,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); @@ -72,3 +79,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 new file mode 100644 index 000000000..da5b2e667 --- /dev/null +++ b/gui/src/client-resource.ts @@ -0,0 +1,301 @@ +import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; + +export type ResourceSnapshot = { + data: T | undefined; + error: unknown; + loading: boolean; +}; + +type Store = { + snapshot: ResourceSnapshot; + 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; + /** 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; + generation: number; +}; + +/** + * 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 }; + +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(), + pollByListener: new Map(), + fetcherByListener: new Map(), + subscriberCount: 0, + pollTimer: null, + pollIntervalMs: undefined, + inflight: null, + inflightOwner: null, + generation: 0, + }; + stores.set(key, store); + } + return store; +} + +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; + } + store.pollIntervalMs = undefined; +} + +/** Prefer a polling subscriber's fetcher; otherwise any remaining subscriber. */ +function pickFetcherEntry( + store: Store, +): { 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 { owner: listener, fetcher }; + } + } + for (const [listener, fetcher] of store.fetcherByListener) { + return { owner: listener, fetcher }; + } + return null; +} + +/** Honor the most aggressive (smallest positive) poll interval among subscribers. */ +function recomputePoll(store: 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); + if (!entry) return; + // Skip ticks while a request is in flight so slow polls can finish. + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner }); + }, pollMs); +} + +async function runFetch( + store: Store, + fetcher: (signal: AbortSignal) => Promise, + options?: { replaceInflight?: boolean; owner?: (() => void) | null; forceLoading?: boolean }, +) { + const replaceInflight = options?.replaceInflight !== false; + if (store.inflight && !replaceInflight) return; + + if (replaceInflight) store.inflight?.abort(); + const controller = new AbortController(); + store.inflight = controller; + store.inflightOwner = options?.owner ?? null; + const gen = ++store.generation; + + // 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); + } + + 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; + store.inflightOwner = null; + } + emit(store); + } +} + +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; +} + +/** + * 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, + pollMs: number | undefined, + onStoreChange: () => void, +) { + const store = getStore(key); + store.listeners.add(onStoreChange); + store.pollByListener.set(onStoreChange, pollMs); + store.fetcherByListener.set(onStoreChange, fetcher); + store.subscriberCount++; + + // 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); + + return () => { + store.listeners.delete(onStoreChange); + 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. + const abortedOwned = abortInflightOwnedBy(store, onStoreChange); + if (store.subscriberCount === 0) { + scheduleStoreEviction(key, store); + 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); + }; +} + +/** 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: (opts?: { forceLoading?: boolean }) => 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 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], + ); + + const getSnapshot = useCallback((): ResourceSnapshot => { + if (!enabled) return EMPTY_SNAPSHOT; + return getStore(key).snapshot; + }, [key, enabled]); + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + 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]); + + return { ...snapshot, refresh }; +} + +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. + * 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: (opts?: { forceLoading?: boolean }) => void } { + 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({ forceLoading: true }); + }); + + 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.inflightOwner = 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 new file mode 100644 index 000000000..a179ee718 --- /dev/null +++ b/gui/src/fetch-json.ts @@ -0,0 +1,35 @@ +/** + * 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). + */ + +/** 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; + return JSON.parse(text) as T; +} + +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 readJsonBody(res); +} + +export async function readJsonIfOk(res: Response): Promise { + if (!res.ok) return null; + return readJsonBody(res); +} 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)}`; +} diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts new file mode 100644 index 000000000..4d27d8600 --- /dev/null +++ b/gui/tests/api-auth-memory.test.ts @@ -0,0 +1,145 @@ +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] }); + } +}); + +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; + 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 () => { + 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; + window.prompt = () => "fresh-token"; + + await installMockAuthFetch(mockFetch); + + 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); +}); + +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); +}); diff --git a/gui/tests/client-resource-poll.test.tsx b/gui/tests/client-resource-poll.test.tsx new file mode 100644 index 000000000..6a16b0962 --- /dev/null +++ b/gui/tests/client-resource-poll.test.tsx @@ -0,0 +1,422 @@ +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, useKeyedClientResource } 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] }); + } +}); + +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"); + 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 waitFor(() => calls.length >= 1); + const beforeUnmount = calls.length; + + await act(async () => { + (window as unknown as { __dropLatest: () => void }).__dropLatest(); + }); + + await waitFor(() => calls.length > beforeUnmount); + + 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(); +}); + +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 waitFor(() => api.readA() === "from-A"); + + await act(async () => { + api.refreshB(); + }); + + await act(async () => { + api.dropB(); + }); + + await act(async () => { + resolveB("from-B"); + }); + // 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 () => { + root.unmount(); + }); + 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 waitFor(() => api.read().data === "from-A" && api.read().loading === false); + + expect(api.read()).toEqual({ data: "from-A", loading: false }); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +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(); + }); + 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(); +});