From 01f8685784f34c66ea1ca6465f2c6ef323d6403a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 01:10:32 +0900 Subject: [PATCH 1/4] perf(gui): share one timer per interval and skip redundant revisit fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine dashboard resources polling at 5s meant nine timers waking to do the same thing; poll scheduling now lives in interval buckets, so a cadence costs one wakeup no matter how many stores share it. Membership changes are bookkeeping, empty buckets are deleted, and the hidden-tab rule moves to bucket granularity (a bucket with no eligible member holds no timer, one visibility listener re-evaluates all buckets). Per-store skip rules still run inside the tick, so opt-out subscribers keep working while paused peers stay silent. Session-cache seeds now carry a write timestamp (legacy untimestamped values read as unknown age and self-heal), and staleAfterMs lets a revisit inside the window paint from cache with no request at all; past it the refetch is quiet, so cached content never flashes a skeleton. Wired into Combos, ApiKeys, ClaudeCode, ClaudeDesktop, Grok, and — via a new opt-in sessionCacheKey on useDataSurface — the ten Integrations resources that had no cache at all. New tests: client-resource-scheduler (4) and client-resource-revalidate (5). --- gui/src/client-resource.ts | 218 +++++++++++------- gui/src/data-surface.ts | 33 ++- gui/src/pages/ApiKeys.tsx | 28 ++- gui/src/pages/ClaudeCode.tsx | 15 +- gui/src/pages/ClaudeDesktop.tsx | 27 ++- gui/src/pages/Combos.tsx | 18 +- gui/src/pages/Grok.tsx | 19 +- .../integrations/FileIntegrationPage.tsx | 14 +- .../integrations/IntegrationsOverview.tsx | 16 +- gui/src/session-list-cache.ts | 49 +++- gui/tests/client-resource-revalidate.test.tsx | 163 +++++++++++++ gui/tests/client-resource-scheduler.test.tsx | 198 ++++++++++++++++ 12 files changed, 678 insertions(+), 120 deletions(-) create mode 100644 gui/tests/client-resource-revalidate.test.tsx create mode 100644 gui/tests/client-resource-scheduler.test.tsx diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 510ada188e..1ae928cb8e 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -33,15 +33,8 @@ type Store = { /** listener → per-attempt deadline owned by that subscriber (undefined = default) */ deadlineByListener: Map<() => void, number | undefined>; subscriberCount: number; - pollTimer: ReturnType | null; /** Currently scheduled poll interval; avoids resetting the countdown on churn. */ pollIntervalMs: number | undefined; - /** - * Hidden with no opt-out subscriber: the timer is GONE (not just skipped) and only - * a visibility transition back to visible re-arms it. pollIntervalMs survives - * suspension so churn cannot re-arm through the changed-interval path. - */ - pollSuspended: boolean; inflight: AbortController | null; /** Subscriber that started the current in-flight request (if any). */ inflightOwner: (() => void) | null; @@ -54,6 +47,11 @@ type Store = { * seeded rows stay indefinitely stale with `lastAttemptOk: true`. */ seedNeedsRevalidate: boolean; + /** + * When the current data last settled successfully (epoch ms). Drives staleAfterMs + * for in-page re-subscribes; a seed carries its own age instead. + */ + lastSettledAt: number | undefined; }; /** @@ -98,14 +96,13 @@ function getStore(key: string): Store { fetcherByListener: new Map(), deadlineByListener: new Map(), subscriberCount: 0, - pollTimer: null, pollIntervalMs: undefined, - pollSuspended: false, inflight: null, inflightOwner: null, visibilityListener: null, generation: 0, seedNeedsRevalidate: false, + lastSettledAt: undefined, }; stores.set(key, store); } @@ -116,27 +113,19 @@ 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; -} - /** - * Clear only the timer and mark the store suspended. Unlike clearPollTimer this - * KEEPS pollIntervalMs — clearing it would let hidden-phase subscriber churn - * (StrictMode, tab mounts) re-arm a fresh interval through recomputePoll's - * changed-interval path. + * One timer per distinct interval, shared by every store polling at that cadence. + * + * Per-store intervals meant a dashboard with nine 5-second resources woke the event + * loop nine times per cycle to do the same thing. A bucket is the same schedule with + * one wakeup: membership changes are bookkeeping, and the per-store skip rules + * (in-flight, hidden opt-out) still run inside the tick. */ -function suspendPollTimer(store: Store) { - if (store.pollTimer !== null) { - clearInterval(store.pollTimer); - store.pollTimer = null; - } - store.pollSuspended = true; -} +type PollBucket = { + timer: ReturnType | null; + stores: Set>; +}; +const pollBuckets = new Map(); /** True when any polling subscriber opted out of hidden pausing (e.g. restart watch). */ function anyOptOut(store: Store): boolean { @@ -148,6 +137,66 @@ function anyOptOut(store: Store): boolean { return false; } +/** A bucket may hold a timer only while some member store is eligible to tick. */ +function bucketShouldRun(bucket: PollBucket): boolean { + if (bucket.stores.size === 0) return false; + if (!documentIsHidden()) return true; + for (const store of bucket.stores) { + if (anyOptOut(store)) return true; + } + return false; +} + +function runBucketTick(bucket: PollBucket) { + for (const store of bucket.stores) { + const entry = pickPollEntry(store); + if (!entry) continue; + // Skip ticks while a request is in flight so slow polls can finish. + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); + } +} + +/** Arm or disarm a bucket's single timer to match its current eligibility. */ +function syncBucketTimer(intervalMs: number, bucket: PollBucket) { + const shouldRun = bucketShouldRun(bucket); + if (shouldRun && bucket.timer === null) { + bucket.timer = setInterval(() => runBucketTick(bucket), intervalMs); + return; + } + if (!shouldRun && bucket.timer !== null) { + clearInterval(bucket.timer); + bucket.timer = null; + } + // An empty bucket is deleted outright, so the map cannot accumulate dead entries. + if (bucket.stores.size === 0) pollBuckets.delete(intervalMs); +} + +/** Re-evaluate every bucket. Called on visibility transitions (one listener, not N). */ +function syncAllBuckets() { + for (const [intervalMs, bucket] of [...pollBuckets]) syncBucketTimer(intervalMs, bucket); +} + +function leavePollBucket(store: Store) { + const current = store.pollIntervalMs; + if (current === undefined) return; + const bucket = pollBuckets.get(current); + store.pollIntervalMs = undefined; + if (!bucket) return; + bucket.stores.delete(store as Store); + syncBucketTimer(current, bucket); +} + +function joinPollBucket(store: Store, intervalMs: number) { + let bucket = pollBuckets.get(intervalMs); + if (!bucket) { + bucket = { timer: null, stores: new Set() }; + pollBuckets.set(intervalMs, bucket); + } + bucket.stores.add(store as Store); + store.pollIntervalMs = intervalMs; + syncBucketTimer(intervalMs, bucket); +} + /** True when the document is currently hidden. Safe on non-browser runtimes. */ function documentIsHidden(): boolean { return typeof document !== "undefined" && document.visibilityState === "hidden"; @@ -199,44 +248,23 @@ function recomputePoll(store: Store) { } } if (pollMs === undefined) { - // No subscriber polls any more: full teardown. A suspended store resets here so - // the next polling subscriber starts clean even while the tab is still hidden — - // otherwise the flag would outlive the listener that resumes it. - clearPollTimer(store); - store.pollSuspended = false; + // No subscriber polls any more: leave the bucket and drop the listener. + leavePollBucket(store); removeVisibilityListener(store); return; } - // Keep the existing countdown when the effective interval is unchanged. - if (pollMs === store.pollIntervalMs && store.pollTimer !== null) { - // Opt-out churn with an unchanged interval: the last opt-out left while hidden — - // the timer would keep waking every interval with zero eligible entries. - if (documentIsHidden() && !anyOptOut(store)) suspendPollTimer(store); - return; - } - if (store.pollSuspended) { - store.pollIntervalMs = pollMs; - // Hidden with no opt-out: bookkeeping only — arming is the visibility handler's - // job. An opt-out subscriber joining a suspended store arms below even while - // hidden: noticing the off-screen event is its documented purpose. - if (documentIsHidden() && !anyOptOut(store)) return; - store.pollSuspended = false; - } - clearPollTimer(store); - store.pollIntervalMs = pollMs; - if (documentIsHidden() && !anyOptOut(store)) { - // First subscribed (or re-armed) while already hidden: hold no timer at all. - // The listener still installs so the resume + make-up path stays live. - store.pollSuspended = true; + if (pollMs === store.pollIntervalMs) { + // Membership unchanged; eligibility may not be (an opt-out subscriber joining or + // leaving while hidden flips whether this bucket may hold a timer at all). + const bucket = pollBuckets.get(pollMs); + if (bucket) syncBucketTimer(pollMs, bucket); ensureVisibilityListener(store); return; } - store.pollTimer = setInterval(() => { - const entry = pickPollEntry(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, deadlineMs: entry.deadlineMs }); - }, pollMs); + // Interval changed: move buckets. syncBucketTimer on both sides keeps the hidden + // rule intact — a bucket with no eligible member holds no timer. + leavePollBucket(store); + joinPollBucket(store, pollMs); ensureVisibilityListener(store); } @@ -254,15 +282,10 @@ function ensureVisibilityListener(store: Store) { if (typeof document === "undefined" || store.visibilityListener) return; const onVisibility = () => { if (store.pollIntervalMs === undefined) return; - if (documentIsHidden()) { - if (anyOptOut(store)) return; // opt-out polls keep their timer running - suspendPollTimer(store); - return; - } - if (store.pollSuspended) { - store.pollSuspended = false; - recomputePoll(store); // re-arms: pollIntervalMs survived the suspension - } + // Buckets are shared, so one transition re-evaluates all of them: a hidden bucket + // with no opt-out member drops its timer entirely, and a visible one re-arms. + syncAllBuckets(); + if (documentIsHidden()) return; const entry = pickFetcherEntry(store); if (!entry) return; void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); @@ -333,6 +356,7 @@ async function runFetch( if (gen !== store.generation || controller.signal.aborted) return; // Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates. store.seedNeedsRevalidate = false; + store.lastSettledAt = Date.now(); store.snapshot = { data, error: undefined, @@ -385,8 +409,7 @@ function abortInflightOwnedBy(store: Store, owner: () => void): boolean { * without wiping cached data. */ function scheduleStoreEviction(key: string, store: Store) { - clearPollTimer(store); - store.pollSuspended = false; + leavePollBucket(store); // The visibility listener exists to wake a poll; with no poll left there is nothing to // wake, and leaving it attached would leak one handler per evicted store. removeVisibilityListener(store); @@ -406,6 +429,7 @@ type ListenerRegistration = { pollMs?: number; pauseWhenHidden?: boolean; deadlineMs?: number; + staleAfterMs?: number; }; function subscribeResource( @@ -413,7 +437,7 @@ function subscribeResource( onStoreChange: () => void, registration: ListenerRegistration, ) { - const { fetcher, pollMs, pauseWhenHidden = true, deadlineMs } = registration; + const { fetcher, pollMs, pauseWhenHidden = true, deadlineMs, staleAfterMs } = registration; const store = getStore(key); store.listeners.add(onStoreChange); store.pollByListener.set(onStoreChange, pollMs); @@ -425,7 +449,12 @@ function subscribeResource( // Cold start, or a pre-subscribe seed that still needs a network check. Keep // cached data across transient 0→1 resubscribe gaps when neither applies. if (store.subscriberCount === 1) { - if (store.snapshot.data === undefined || store.seedNeedsRevalidate) { + // A surviving store (in-page enabled-gate churn) revalidates quietly once its data + // ages past the window; cached data stays on screen while it runs. + const stale = typeof staleAfterMs === "number" + && store.lastSettledAt !== undefined + && Date.now() - store.lastSettledAt > staleAfterMs; + if (store.snapshot.data === undefined || store.seedNeedsRevalidate || stale) { void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange, deadlineMs }); } } @@ -477,13 +506,30 @@ export interface ClientResourceOptions { * wedging the store. */ deadlineMs?: number; + /** + * Revalidate on (re)subscribe when the data is older than this. Opt-in: without it a + * seeded store keeps today's behavior. Quiet by construction — cached data stays + * visible while the refetch runs, so a revisit never flashes a skeleton. + */ + staleAfterMs?: number; + /** + * Age evidence for `initialData`, from readSessionListCacheEntry. A seed with no + * known age counts as stale, so legacy caches self-heal on first use. + */ + initialDataCachedAt?: number | null; } /** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */ -function seedClientResourceIfEmpty(key: string, data: T): void { +function seedClientResourceIfEmpty(key: string, data: T, cachedAt?: number | null, staleAfterMs?: number): void { const store = getStore(key); if (store.subscriberCount !== 0 || store.snapshot.data !== undefined) return; setClientResourceData(key, data); + // A seed fresher than the staleness window skips the mount revalidation entirely: + // that is the whole request saved on a tab revisit. Unknown age counts as stale. + if (typeof staleAfterMs === "number" && typeof cachedAt === "number" && Date.now() - cachedAt < staleAfterMs) { + store.seedNeedsRevalidate = false; + store.lastSettledAt = cachedAt; + } } export function useClientResource( @@ -497,8 +543,9 @@ export function useClientResource( // must keep running while hidden, such as waiting for a restarted server to answer. const pauseWhenHidden = options?.pauseWhenHidden !== false; const deadlineMs = options?.deadlineMs; + const staleAfterMs = options?.staleAfterMs; if (enabled && options?.initialData !== undefined) { - seedClientResourceIfEmpty(key, options.initialData); + seedClientResourceIfEmpty(key, options.initialData, options.initialDataCachedAt, staleAfterMs); } const fetcherRef = useRef(fetcher); // Sync latest fetcher every commit. No dep array on purpose: inline fetchers are @@ -518,9 +565,9 @@ export function useClientResource( (onStoreChange: () => void) => { if (!enabled) return () => {}; listenerRef.current = onStoreChange; - return subscribeResource(key, onStoreChange, { fetcher: stableFetcher, pollMs, pauseWhenHidden, deadlineMs }); + return subscribeResource(key, onStoreChange, { fetcher: stableFetcher, pollMs, pauseWhenHidden, deadlineMs, staleAfterMs }); }, - [key, stableFetcher, pollMs, enabled, pauseWhenHidden, deadlineMs], + [key, stableFetcher, pollMs, enabled, pauseWhenHidden, deadlineMs, staleAfterMs], ); const getSnapshot = useCallback((): ResourceSnapshot => { @@ -605,18 +652,24 @@ export function setClientResourceData(key: string, data: T) { // Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation // results) already hold the fresh value and must not schedule a redundant GET. store.seedNeedsRevalidate = store.subscriberCount === 0; + store.lastSettledAt = Date.now(); emit(store); } /** Test-only: drop every module cache entry so suite order cannot skip cold-start fetches. */ export function clearClientResourceStoresForTests(): void { for (const store of stores.values()) { - clearPollTimer(store); + leavePollBucket(store); + removeVisibilityListener(store); store.inflight?.abort(); store.inflight = null; store.inflightOwner = null; } stores.clear(); + for (const [intervalMs, bucket] of [...pollBuckets]) { + if (bucket.timer !== null) clearInterval(bucket.timer); + pollBuckets.delete(intervalMs); + } } /** @@ -626,5 +679,12 @@ export function clearClientResourceStoresForTests(): void { */ export function hasPollTimerForTests(key: string): boolean { const store = stores.get(key); - return store ? store.pollTimer !== null : false; + if (!store || store.pollIntervalMs === undefined) return false; + const bucket = pollBuckets.get(store.pollIntervalMs); + return bucket ? bucket.stores.has(store as Store) && bucket.timer !== null : false; +} + +/** Test-only: how many distinct interval buckets exist (empty ones must be deleted). */ +export function pollBucketCountForTests(): number { + return pollBuckets.size; } diff --git a/gui/src/data-surface.ts b/gui/src/data-surface.ts index f587e56596..59178c0780 100644 --- a/gui/src/data-surface.ts +++ b/gui/src/data-surface.ts @@ -8,6 +8,7 @@ */ import { type ResourceSnapshot, useKeyedClientResource } from "./client-resource"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "./session-list-cache"; export type DataSurfaceKind = | "disabled" @@ -47,6 +48,16 @@ export type DataSurfaceOptions = { initialData?: T; /** Forwarded to the resource layer; see ClientResourceOptions.deadlineMs. */ deadlineMs?: number; + /** Forwarded to the resource layer; see ClientResourceOptions.staleAfterMs. */ + staleAfterMs?: number; + /** Forwarded to the resource layer; see ClientResourceOptions.initialDataCachedAt. */ + initialDataCachedAt?: number | null; + /** + * Opt-in session-cache wiring: the surface seeds from this key on mount and writes + * every successful payload back with its timestamp. Pages that already own their + * cache keep doing it themselves and leave this unset. + */ + sessionCacheKey?: string; }; /** @@ -146,8 +157,26 @@ export function useDataSurface( load: (signal: AbortSignal) => Promise, options: DataSurfaceOptions, ): DataSurfaceResource { - const { isEmpty, ...resourceOptions } = options; - const resource = useKeyedClientResource(key, deps, load, resourceOptions); + const { isEmpty, sessionCacheKey, ...resourceOptions } = options; + // Read once per render; sessionStorage is synchronous and the seed only applies + // while the store is empty, so a repeat read costs nothing and stays in sync. + const cachedEntry = sessionCacheKey ? readSessionListCacheEntry(sessionCacheKey) : null; + const loadAndCache = sessionCacheKey + ? async (signal: AbortSignal): Promise => { + const next = await load(signal); + writeSessionListCacheEntry(sessionCacheKey, next); + return next; + } + : load; + const resource = useKeyedClientResource(key, deps, loadAndCache, { + ...resourceOptions, + ...(sessionCacheKey + ? { + initialData: resourceOptions.initialData ?? cachedEntry?.data, + initialDataCachedAt: resourceOptions.initialDataCachedAt ?? cachedEntry?.cachedAt ?? null, + } + : {}), + }); return { ...resource, state: classifyDataSurface(resource, isEmpty, options.enabled !== false), diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 7b23584129..f5ce59ca87 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -9,7 +9,7 @@ import { type ExternalModelRow, type GatewayInboundProtocol, } from "../api-access-models"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { createBoundedFetch } from "../bounded-fetch"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -104,8 +104,10 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a // A cache entry is arbitrary parsed JSON. Trusting it would reintroduce exactly // what the network path refuses: an empty matrix rendering as an authoritative // "no rules" table, or a row without `usage` throwing on first render. - const cachedKeys = validCachedKeys(readSessionListCache(keysCacheKey)); - const cachedModels = readSessionListCache(modelsCacheKey); + const cachedKeysEntry = readSessionListCacheEntry(keysCacheKey); + const cachedModelsEntry = readSessionListCacheEntry(modelsCacheKey); + const cachedKeys = validCachedKeys(cachedKeysEntry?.data ?? null); + const cachedModels = cachedModelsEntry?.data ?? null; const [actionError, setActionError] = useState(null); const [modelQuery, setModelQuery] = useState(""); const [copiedModelId, setCopiedModelId] = useState(null); @@ -144,7 +146,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a authMatrix: data.authMatrix, }; // Prefixes only — never the secret key material. - writeSessionListCache(keysCacheKey, next); + writeSessionListCacheEntry(keysCacheKey, next); return next; }, [apiBase, keysCacheKey, t]); @@ -166,7 +168,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a )) .map(row => classifyExternalModel(row)) .sort((a, b) => externalModelId(a).localeCompare(externalModelId(b))); - writeSessionListCache(modelsCacheKey, rows); + writeSessionListCacheEntry(modelsCacheKey, rows); return rows; }, [apiBase, modelsCacheKey, t]); @@ -176,13 +178,25 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a keysResourceKey, [apiBase], fetchKeys, - { isEmpty: data => data.keys.length === 0, initialData: cachedKeys ?? undefined, enabled: active }, + { + isEmpty: data => data.keys.length === 0, + initialData: cachedKeys ?? undefined, + initialDataCachedAt: cachedKeysEntry?.cachedAt ?? null, + staleAfterMs: 60_000, + enabled: active, + }, ); const modelsResource = useDataSurface( modelsResourceKey, [apiBase], fetchModels, - { isEmpty: models => models.length === 0, initialData: cachedModels ?? undefined, enabled: active }, + { + isEmpty: models => models.length === 0, + initialData: cachedModels ?? undefined, + initialDataCachedAt: cachedModelsEntry?.cachedAt ?? null, + staleAfterMs: 60_000, + enabled: active, + }, ); const keysState = keysResource.state; const modelsState = modelsResource.state; diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 9329caf713..c8cb25ad0e 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { Notice, Switch } from "../ui"; import { useI18n, useT, LOCALES } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { backgroundHelperOptions } from "./claude-code-helper-options"; @@ -28,7 +28,8 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en"; const cacheKey = `ocx.claude-code.v1:${apiBase}`; const resourceKey = `claude-code:${apiBase}`; - const cached = useMemo(() => readSessionListCache(cacheKey), [cacheKey]); + const cachedEntry = useMemo(() => readSessionListCacheEntry(cacheKey), [cacheKey]); + const cached = cachedEntry?.data ?? null; const [draftState, setState] = useState(() => cached?.state ?? null); const [draftRows, setRows] = useState(() => cached?.rows ?? []); const [hasDraftRows, setHasDraftRows] = useState(Boolean(cached)); @@ -76,7 +77,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string setState(nextState); setRows(nextRows); setHasDraftRows(true); - writeSessionListCache(cacheKey, next); + writeSessionListCacheEntry(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -84,7 +85,13 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string resourceKey, [apiBase], fetchCode, - { isEmpty: () => false, enabled: active, initialData: cached ?? undefined }, + { + isEmpty: () => false, + enabled: active, + initialData: cached ?? undefined, + initialDataCachedAt: cachedEntry?.cachedAt ?? null, + staleAfterMs: 60_000, + }, ); const loadState = codeResource.state; const data = loadState.data ?? cached; diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index f2cddba1d8..9c17a994da 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -5,7 +5,7 @@ import { IconChevron } from "../icons"; import { EmptyState, Notice } from "../ui"; import { LOCALES, useI18n, type TFn, type TKey } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -126,7 +126,11 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null { type CachedDesktop = { data: DesktopResponse; profile: DesktopProfile }; function readDesktopCache(cacheKey: string): CachedDesktop | null { - return readSessionListCache(cacheKey); + return readSessionListCacheEntry(cacheKey)?.data ?? null; +} + +function readDesktopCachedAt(cacheKey: string): number | null { + return readSessionListCacheEntry(cacheKey)?.cachedAt ?? null; } function seedDesktop(cacheKey: string) { @@ -204,7 +208,7 @@ export default function ClaudeDesktop({ for (const model of payload.models) counts[normalized.assignments[model.route]?.family ?? "opus"] += 1; setCollapsedFamilies(defaultCollapsedFamilies(counts)); } - writeSessionListCache(cacheKey, next); + writeSessionListCacheEntry(cacheKey, next); return next; }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile]); @@ -212,7 +216,13 @@ export default function ClaudeDesktop({ resourceKey, [apiBase], fetchDesktop, - { isEmpty: () => false, enabled: active, initialData: cached.held ?? undefined }, + { + isEmpty: () => false, + enabled: active, + initialData: cached.held ?? undefined, + initialDataCachedAt: readDesktopCachedAt(cacheKey), + staleAfterMs: 60_000, + }, ); const loadState = desktopResource.state; const resourceData = loadState.data ?? (cached.data && cached.profile ? { data: cached.data, profile: cached.profile } : null); @@ -260,7 +270,7 @@ export default function ClaudeDesktop({ // profile editor, which keeps its drafts intact across Code/Desktop tab switches. const statusCacheKey = `ocx.claude-desktop.status.v1:${apiBase}`; const statusResourceKey = `claude-desktop-status:${apiBase}`; - const cachedStatus = readSessionListCache(statusCacheKey); + const cachedStatusEntry = readSessionListCacheEntry(statusCacheKey); const statusResource = useDataSurface( statusResourceKey, [apiBase], @@ -268,13 +278,14 @@ export default function ClaudeDesktop({ const response = await fetch(`${apiBase}/api/claude-desktop/status`, { signal }); const next = await readJsonIfOk(response); if (!next) throw new Error("Claude Desktop status unavailable"); - writeSessionListCache(statusCacheKey, next); + writeSessionListCacheEntry(statusCacheKey, next); return next; }, - { isEmpty: () => false, pollMs: 5000, enabled: active, initialData: cachedStatus ?? undefined }, + // Polled, so no staleAfterMs: the cadence already keeps it fresh. + { isEmpty: () => false, pollMs: 5000, enabled: active, initialData: cachedStatusEntry?.data ?? undefined }, ); const statusState = statusResource.state; - const status = statusState.data ?? cachedStatus ?? null; + const status = statusState.data ?? cachedStatusEntry?.data ?? null; const statusFailed = statusState.showError; const moveModel = (route: string, family: Family) => { diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 691ba1146c..da924893ca 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -7,7 +7,7 @@ import { toPutBody, } from "../combo-workspace-data"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { Notice } from "../ui"; import { useT } from "../i18n/shared"; import { useDataSurface } from "../data-surface"; @@ -49,7 +49,11 @@ function responseSucceeded(data: unknown): boolean { } function seedCombos(cacheKey: string): CachedCombosPage | null { - return readSessionListCache(cacheKey); + return readSessionListCacheEntry(cacheKey)?.data ?? null; +} + +function seedCombosCachedAt(cacheKey: string): number | null { + return readSessionListCacheEntry(cacheKey)?.cachedAt ?? null; } export default function Combos({ @@ -187,7 +191,7 @@ export default function Combos({ } const next = { combos, providers, models, cataloguedComboIds: [...catalogued] } satisfies CachedCombosPage; - writeSessionListCache(cacheKey, next); + writeSessionListCacheEntry(cacheKey, next); // Retain the coherent payload here — one place, on the success path, never during // render. See the `retainedData` note below. setRetainedData(next); @@ -204,7 +208,13 @@ export default function Combos({ * Disabling reports `data: undefined`, so `retainedData` below keeps the last good * payload and the subtree never unmounts. */ - { isEmpty: () => false, initialData: cached ?? undefined, enabled: active }, + { + isEmpty: () => false, + initialData: cached ?? undefined, + initialDataCachedAt: seedCombosCachedAt(cacheKey), + staleAfterMs: 60_000, + enabled: active, + }, ); const { state } = resource; diff --git a/gui/src/pages/Grok.tsx b/gui/src/pages/Grok.tsx index dde14400ba..d68d510016 100644 --- a/gui/src/pages/Grok.tsx +++ b/gui/src/pages/Grok.tsx @@ -3,7 +3,7 @@ import { EmptyState, Notice, Switch } from "../ui"; import { IconChevron } from "../icons"; import { useT, type TKey } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { setClientResourceData } from "../client-resource"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -65,7 +65,8 @@ function formatContext(value: number | undefined, t: TFn): string { export default function Grok({ apiBase, active = true }: { apiBase: string; active?: boolean }) { const t = useT(); const cacheKey = `ocx.grok.status.v1:${apiBase}`; - const cached = readSessionListCache(cacheKey); + const cachedEntry = readSessionListCacheEntry(cacheKey); + const cached = cachedEntry?.data ?? null; // Local edits are an OVERLAY on the server's selection rather than a copy of it. Copying meant // reconciling in an effect, which both fought the switches mid-interaction and tripped the // cascading-render lint; `null` here means "no unsaved edits, follow the server". @@ -83,7 +84,7 @@ export default function Grok({ apiBase, active = true }: { apiBase: string; acti // Tolerate an older proxy that predates the selection routes: the page degrades // to the read-only fence view instead of crashing on a missing field. const next = { ...payload, candidates: payload.candidates ?? [], excluded: payload.excluded ?? [] }; - writeSessionListCache(cacheKey, next); + writeSessionListCacheEntry(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -94,7 +95,15 @@ export default function Grok({ apiBase, active = true }: { apiBase: string; acti resourceKey, [apiBase], fetchStatus, - { isEmpty: () => false, initialData: cached ?? undefined, enabled: active }, + { + isEmpty: () => false, + initialData: cached ?? undefined, + initialDataCachedAt: cachedEntry?.cachedAt ?? null, + // A revisit within the window paints from cache with no request at all; past it, + // the refetch is quiet (cached rows stay on screen). + staleAfterMs: 60_000, + enabled: active, + }, ); const { state } = resource; const load = resource.refresh; @@ -184,7 +193,7 @@ export default function Grok({ apiBase, active = true }: { apiBase: string; acti setMessage({ tone: "ok", text: t("grok.saved") }); setAnnouncement(t("grok.saved")); if (status) { - writeSessionListCache(cacheKey, { ...status, excluded: acknowledged }); + writeSessionListCacheEntry(cacheKey, { ...status, excluded: acknowledged }); } } } catch (err) { diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index ba9e67f392..5b30cbae88 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -74,13 +74,23 @@ export default function FileIntegrationPage({ `integration-state:${apiBase}:${client}`, [apiBase, client], fetchState, - { isEmpty: () => false, enabled: active }, + { + isEmpty: () => false, + enabled: active, + sessionCacheKey: `ocx.integrations.state.v1:${apiBase}:${client}`, + staleAfterMs: 60_000, + }, ); const historyResource = useDataSurface( `integration-journal:${apiBase}:${client}`, [apiBase, client], fetchHistory, - { isEmpty: rows => rows.length === 0, enabled: active }, + { + isEmpty: rows => rows.length === 0, + enabled: active, + sessionCacheKey: `ocx.integrations.client-journal.v1:${apiBase}:${client}`, + staleAfterMs: 60_000, + }, ); const status = stateResource.state.data ?? null; diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 79788f036b..2bf34b508d 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -216,19 +216,19 @@ export default function IntegrationsOverview({ `integration-states:${apiBase}`, [apiBase], fetchStates, - { isEmpty: rows => rows.length === 0, enabled: active }, + { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const historyResource = useDataSurface( `integration-journal-all:${apiBase}`, [apiBase], fetchHistory, - { isEmpty: rows => rows.length === 0, enabled: active }, + { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.journal.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const codexResource = useDataSurface( `integration-codex:${apiBase}`, [apiBase], fetchCodex, - { isEmpty: value => value === null, enabled: active }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.codex.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const keysResource = useDataSurface( `integration-keys:${apiBase}`, @@ -236,31 +236,31 @@ export default function IntegrationsOverview({ fetchKeyCount, // The loader now throws instead of resolving null, so null is not a value // it can produce. Leaving the old predicate would outlive its contract. - { isEmpty: () => false, enabled: active }, + { isEmpty: () => false, enabled: active, sessionCacheKey: `ocx.integrations.keys.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const claudeResource = useDataSurface( `integration-claude:${apiBase}`, [apiBase], fetchClaude, - { isEmpty: value => value === null, enabled: active }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const claudeDesktopResource = useDataSurface( `integration-claude-desktop:${apiBase}`, [apiBase], fetchClaudeDesktop, - { isEmpty: value => value === null, enabled: active }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude-desktop.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const grokResource = useDataSurface( `integration-grok:${apiBase}`, [apiBase], fetchGrok, - { isEmpty: value => value === null, enabled: active }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.grok.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const nativeResource = useDataSurface( `integration-native:${apiBase}`, [apiBase], fetchNative, - { isEmpty: value => value === null, enabled: active }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.native.v1:${apiBase}`, staleAfterMs: 60_000 }, ); const clients = statesResource.state.data ?? []; diff --git a/gui/src/session-list-cache.ts b/gui/src/session-list-cache.ts index e970f83348..d0166f8348 100644 --- a/gui/src/session-list-cache.ts +++ b/gui/src/session-list-cache.ts @@ -3,16 +3,63 @@ * Never store API keys, tokens, or credentials here — XSS can read sessionStorage. */ +/** Envelope marker: distinguishes a timestamped entry from a legacy raw value. */ +const CACHED_AT_KEY = "__ocxCachedAt"; + +export type SessionListEntry = { + data: T; + /** null when the cache predates timestamping — treated as unknown age (stale). */ + cachedAt: number | null; +}; + export function readSessionListCache(key: string): T | null { try { const raw = sessionStorage.getItem(key); if (!raw) return null; - return JSON.parse(raw) as T; + const parsed = JSON.parse(raw) as unknown; + // Transparent to callers that do not care about age. + if (isEntryEnvelope(parsed)) return parsed.data as T; + return parsed as T; + } catch { + return null; + } +} + +function isEntryEnvelope(value: unknown): value is { [CACHED_AT_KEY]: number; data: unknown } { + return typeof value === "object" + && value !== null + && typeof (value as Record)[CACHED_AT_KEY] === "number" + && "data" in value; +} + +/** + * Read a seed with its age. A legacy (untimestamped) value reads as `cachedAt: null`, + * which every caller treats as stale — so an existing cache self-heals on first use + * instead of pinning old data. + */ +export function readSessionListCacheEntry(key: string): SessionListEntry | null { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (isEntryEnvelope(parsed)) { + return { data: parsed.data as T, cachedAt: parsed[CACHED_AT_KEY] }; + } + return { data: parsed as T, cachedAt: null }; } catch { return null; } } +/** Write a seed with its write time so a revisit can decide whether to revalidate. */ +export function writeSessionListCacheEntry(key: string, data: T): void { + try { + sessionStorage.setItem(key, JSON.stringify({ [CACHED_AT_KEY]: Date.now(), data })); + } catch { + /* private mode / quota */ + } +} + export function writeSessionListCache(key: string, value: unknown): void { try { sessionStorage.setItem(key, JSON.stringify(value)); diff --git a/gui/tests/client-resource-revalidate.test.tsx b/gui/tests/client-resource-revalidate.test.tsx new file mode 100644 index 0000000000..f367f2601c --- /dev/null +++ b/gui/tests/client-resource-revalidate.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, expect, test as bunTest } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests, useClientResource } from "../src/client-resource"; +import { classifyDataSurface } from "../src/data-surface"; +import { + readSessionListCache, + readSessionListCacheEntry, + writeSessionListCache, + writeSessionListCacheEntry, +} from "../src/session-list-cache"; + +function test(name: string, fn: () => void | Promise): void { + bunTest(name, fn, { timeout: 30_000 }); +} + +const globals = ["document", "window", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + clearClientResourceStoresForTests(); + 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 }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 15_000): 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)); + }); + } +} + +type Probe = { current: ReturnType> | null }; + +async function mountSeeded(opts: { + key: string; + fetcher: () => Promise; + seed: string; + cachedAt: number | null; + staleAfterMs?: number; +}): Promise<{ probe: Probe; root: Root }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const probe: Probe = { current: null }; + function Page() { + probe.current = useClientResource(opts.key, opts.fetcher, { + initialData: opts.seed, + initialDataCachedAt: opts.cachedAt, + staleAfterMs: opts.staleAfterMs, + }); + return null; + } + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + return { probe, root }; +} + +test("a seed younger than staleAfterMs skips the mount refetch entirely", async () => { + let fetches = 0; + const { probe, root } = await mountSeeded({ + key: `reval-fresh-${Date.now()}`, + fetcher: async () => { fetches += 1; return "live"; }, + seed: "seeded", + cachedAt: Date.now() - 100, + staleAfterMs: 60_000, + }); + expect(probe.current?.data).toBe("seeded"); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 60)); + }); + expect(fetches).toBe(0); // the saved request + await act(async () => { root.unmount(); }); +}); + +test("a seed older than staleAfterMs quietly revalidates without a skeleton", async () => { + let fetches = 0; + let release: ((value: string) => void) | null = null; + const { probe, root } = await mountSeeded({ + key: `reval-stale-${Date.now()}`, + // Held open so the mid-revalidation state is observable rather than already past. + fetcher: () => { fetches += 1; return new Promise((resolve) => { release = resolve; }); }, + seed: "seeded", + cachedAt: Date.now() - 120_000, + staleAfterMs: 60_000, + }); + await waitFor(() => probe.current?.refreshing === true); + // Cached data stays visible while the refetch runs — never a cold skeleton. + const during = classifyDataSurface(probe.current!, () => false, true); + expect(during.showSkeleton).toBe(false); + expect(during.data).toBe("seeded"); + expect(during.kind).toBe("loading-with-stale-data"); + await act(async () => { release!("live"); }); + await waitFor(() => probe.current?.data === "live"); + expect(fetches).toBe(1); + await act(async () => { root.unmount(); }); +}); + +test("a legacy seed with unknown age counts as stale and self-heals", async () => { + let fetches = 0; + const { probe, root } = await mountSeeded({ + key: `reval-legacy-${Date.now()}`, + fetcher: async () => { fetches += 1; return "live"; }, + seed: "seeded", + cachedAt: null, + staleAfterMs: 60_000, + }); + await waitFor(() => probe.current?.data === "live"); + expect(fetches).toBe(1); + await act(async () => { root.unmount(); }); +}); + +test("without staleAfterMs a seed keeps today's always-revalidate behavior", async () => { + let fetches = 0; + const { probe, root } = await mountSeeded({ + key: `reval-default-${Date.now()}`, + fetcher: async () => { fetches += 1; return "live"; }, + seed: "seeded", + cachedAt: Date.now() - 10, + }); + await waitFor(() => probe.current?.data === "live"); + expect(fetches).toBe(1); + await act(async () => { root.unmount(); }); +}); + +test("session cache entries round-trip their age and read legacy values", () => { + const key = `entry-${Date.now()}`; + writeSessionListCacheEntry(key, { rows: [1, 2, 3] }); + const entry = readSessionListCacheEntry<{ rows: number[] }>(key); + expect(entry?.data.rows).toEqual([1, 2, 3]); + expect(typeof entry?.cachedAt).toBe("number"); + // The plain reader stays transparent for callers that ignore age. + expect(readSessionListCache<{ rows: number[] }>(key)?.rows).toEqual([1, 2, 3]); + + const legacyKey = `entry-legacy-${Date.now()}`; + writeSessionListCache(legacyKey, { rows: [9] }); + const legacy = readSessionListCacheEntry<{ rows: number[] }>(legacyKey); + expect(legacy?.data.rows).toEqual([9]); + expect(legacy?.cachedAt).toBeNull(); +}); diff --git a/gui/tests/client-resource-scheduler.test.tsx b/gui/tests/client-resource-scheduler.test.tsx new file mode 100644 index 0000000000..b7636cc2ca --- /dev/null +++ b/gui/tests/client-resource-scheduler.test.tsx @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, expect, test as bunTest } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { + clearClientResourceStoresForTests, + hasPollTimerForTests, + pollBucketCountForTests, + useClientResource, +} from "../src/client-resource"; + +function test(name: string, fn: () => void | Promise): void { + bunTest(name, fn, { timeout: 30_000 }); +} + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + clearClientResourceStoresForTests(); + 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(() => { + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 15_000): 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)); + }); + } +} + +async function setVisibility(state: "visible" | "hidden"): Promise { + Object.defineProperty(testWindow.document, "visibilityState", { + configurable: true, + get: () => state, + }); + await act(async () => { + testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); + await Promise.resolve(); + }); +} + +test("stores sharing an interval share ONE timer bucket", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const stamp = Date.now(); + const fetches: Record = { a: 0, b: 0, c: 0 }; + + function Page() { + useClientResource(`sched-a-${stamp}`, async () => { fetches.a += 1; return "a"; }, { pollMs: 30 }); + useClientResource(`sched-b-${stamp}`, async () => { fetches.b += 1; return "b"; }, { pollMs: 30 }); + useClientResource(`sched-c-${stamp}`, async () => { fetches.c += 1; return "c"; }, { pollMs: 30 }); + return null; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => fetches.a >= 1 && fetches.b >= 1 && fetches.c >= 1); + + // Three polling stores, one interval → exactly one bucket (and one timer). + expect(pollBucketCountForTests()).toBe(1); + // Every member still ticks on that shared timer. + const at = { ...fetches }; + await waitFor(() => fetches.a > at.a && fetches.b > at.b && fetches.c > at.c); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("an interval change moves the store between buckets and empty buckets are deleted", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const KEY = `sched-move-${Date.now()}`; + let fetches = 0; + + function Page({ pollMs }: { pollMs: number }) { + useClientResource(KEY, async () => { fetches += 1; return "v"; }, { pollMs }); + return null; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => fetches >= 1); + expect(pollBucketCountForTests()).toBe(1); + + await act(async () => { root.render(); }); + // The 30ms bucket is now empty and must be gone, not merely stopped. + expect(pollBucketCountForTests()).toBe(1); + expect(hasPollTimerForTests(KEY)).toBe(true); + + await act(async () => { root.unmount(); }); + await waitFor(() => pollBucketCountForTests() === 0); + container.remove(); +}); + +test("hidden drops the shared timer; visible re-arms it and makes up per store", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const stamp = Date.now(); + const KEY_A = `sched-hide-a-${stamp}`; + const KEY_B = `sched-hide-b-${stamp}`; + const fetches: Record = { a: 0, b: 0 }; + + function Page() { + useClientResource(KEY_A, async () => { fetches.a += 1; return "a"; }, { pollMs: 30 }); + useClientResource(KEY_B, async () => { fetches.b += 1; return "b"; }, { pollMs: 30 }); + return null; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => fetches.a >= 1 && fetches.b >= 1); + expect(hasPollTimerForTests(KEY_A)).toBe(true); + + await setVisibility("hidden"); + expect(hasPollTimerForTests(KEY_A)).toBe(false); + expect(hasPollTimerForTests(KEY_B)).toBe(false); + const atHide = { ...fetches }; + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 120)); + }); + expect(fetches.a).toBe(atHide.a); + expect(fetches.b).toBe(atHide.b); + + await setVisibility("visible"); + await waitFor(() => hasPollTimerForTests(KEY_A)); + await waitFor(() => fetches.a > atHide.a && fetches.b > atHide.b); + + await act(async () => { root.unmount(); }); + container.remove(); + await setVisibility("visible"); +}); + +test("an opt-out member keeps its bucket alive while hidden, and only it runs", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const stamp = Date.now(); + const KEY_PAUSED = `sched-mixed-paused-${stamp}`; + const KEY_OPTOUT = `sched-mixed-optout-${stamp}`; + const fetches: Record = { paused: 0, optOut: 0 }; + + function Page() { + useClientResource(KEY_PAUSED, async () => { fetches.paused += 1; return "p"; }, { pollMs: 30 }); + useClientResource(KEY_OPTOUT, async () => { fetches.optOut += 1; return "o"; }, { pollMs: 30, pauseWhenHidden: false }); + return null; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => fetches.paused >= 1 && fetches.optOut >= 1); + expect(pollBucketCountForTests()).toBe(1); + + await setVisibility("hidden"); + // The bucket keeps its timer for the opt-out member... + expect(hasPollTimerForTests(KEY_OPTOUT)).toBe(true); + const atHide = { ...fetches }; + await waitFor(() => fetches.optOut > atHide.optOut); + // ...but the paused store still fires nothing (per-store rule inside the tick). + expect(fetches.paused).toBe(atHide.paused); + + await act(async () => { root.unmount(); }); + container.remove(); + await setVisibility("visible"); +}); From 1532bc758c0df7a62d7864d74dfb36c78d4aaa0e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 01:24:49 +0900 Subject: [PATCH 2/4] docs(devlog): record WP4 landing, measurements, and deviations (040 D addendum) --- .../040_phase4_poll_consolidation.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md b/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md index 47c700e34b..cf62de1b76 100644 --- a/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md +++ b/devlog/_plan/260816_gui_loading_performance/040_phase4_poll_consolidation.md @@ -160,3 +160,46 @@ else add cases to client-resource-revalidate.test.tsx): Server-side endpoint merging, render-level memoization audits, virtualization tuning, bundle-size work. + +## D addendum — landed (2026-08-16/17) + +Implementation: commit d6ea35ef0. + +### Measurement (CDP, sandboxed instance on :5198, same protocol as 001/E0) + +| Scenario | Before | After | +|---|---|---| +| Dashboard, 31s dwell, visible | 57 requests / 9 concurrent 5s timers | 57 requests / **1** shared 5s timer | +| Integrations revisit inside 60s | full refetch of 8 overview resources, skeletons on mount | **0 requests, 0 skeletons** | +| Hidden tab (any page) | WP3 guarantee | unchanged: zero timers, zero requests | + +The visible-tab request count is deliberately unchanged: bucketing removes wakeups, +not cadence, and freshness (not volume) is what a live dashboard is for. The real +volume win is the revisit path — previously every tab hop re-fetched everything +because `scheduleStoreEviction` drops the store on route change. + +### Interval tuning decision (§3) + +No cadence changed. The measurement does not show Logs (2s) or Debug (1s+2s) +dominating: they are page-gated (`enabled: tab === "logs"` / `active`), so they +contribute nothing unless the user is looking at them, and both are now +visibility-paused and in-flight-guarded by WP3. Changing a freshness-affecting +default without the numbers supporting it would be exactly the kind of unforced +regression this section exists to prevent. + +### Deviations from spec + +- The spec assumed the Integrations pages would gain hand-written cache wiring. + Ten resources across two files needed the identical seed+write pair, so + `useDataSurface` gained an opt-in `sessionCacheKey` instead and the pages pass a + key. Same mechanism, one implementation. +- `startVisibilityPoll` schedules through `window.setInterval` when available. The + migrated pollers all used the window timer and their tests intercept it there; + the bare global bound to a different scope and broke nine tests (found by running + the full suite, fixed before commit). + +### Verification + +- `cd gui && bun test tests` → **922 pass / 0 fail** (157 files), exit 0. +- `bun run lint`, `bun run lint:i18n`, `bun run build` → all green. +- Browser: revisit flow measured above; dashboard poll wave unchanged and healthy. From 379c9cba1e9a359e3708fae7bec68a993429b602 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 01:58:19 +0900 Subject: [PATCH 3/4] fix(gui): never let a mirrored surface serve stale state, and share one visibility listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a real regression in the revisit work: the Integrations overview and the per-client page describe the same connection through different cache keys, so a toggle on one could be contradicted by the other for up to 60s, with no request in flight to correct it. Those ten resources now seed from cache without a staleness window — a revisit still paints instead of flashing a skeleton, but it always revalidates. The window stays where a surface owns its truth (Combos, ApiKeys, ClaudeCode, ClaudeDesktop, Grok). A test pins the distinction so the next person adding a cache key has to decide which kind it is. Also from review: the per-store visibilitychange handlers collapse into one module listener (each was running the same global bucket sweep, N times per flip), and the session-cache seed read is memoized per key rather than re-parsed on every render of an eight-resource page. --- gui/src/client-resource.ts | 51 +++++++++++-------- gui/src/data-surface.ts | 28 +++++++--- .../integrations/FileIntegrationPage.tsx | 2 - .../integrations/IntegrationsOverview.tsx | 16 +++--- .../integrations-cache-freshness.test.ts | 34 +++++++++++++ 5 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 gui/tests/integrations-cache-freshness.test.ts diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 1ae928cb8e..1ec40fc28f 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -38,8 +38,6 @@ type Store = { inflight: AbortController | null; /** Subscriber that started the current in-flight request (if any). */ inflightOwner: (() => void) | null; - /** Store-level visibilitychange handler, installed only while this store polls. */ - visibilityListener: (() => void) | null; generation: number; /** * Set when `setClientResourceData` publishes while nobody is subscribed (session-cache @@ -99,7 +97,6 @@ function getStore(key: string): Store { pollIntervalMs: undefined, inflight: null, inflightOwner: null, - visibilityListener: null, generation: 0, seedNeedsRevalidate: false, lastSettledAt: undefined, @@ -269,37 +266,43 @@ function recomputePoll(store: Store) { } /** - * One listener per polling store, installed whenever a polling subscriber exists — - * regardless of whether a timer is currently armed (a mount-while-hidden store has - * no timer but MUST still resume). On hidden the timer is suspended outright (zero - * wakeups, unless an opt-out subscriber keeps it); on visible the skipped ticks are - * made up with a single quiet revalidation and the cadence re-arms. + * ONE listener for the whole module, not one per store. Timers are shared by bucket, + * so a visibility flip is a single global re-evaluation: hidden buckets with no + * opt-out member drop their timers outright (zero wakeups), visible ones re-arm, and + * every store that polls gets one quiet make-up fetch. A per-store listener would run + * that same global sweep N times per flip. * * `replaceInflight: false` keeps this from cancelling work a visible-again mount just * started; if something is already loading, that request is the fresh answer. */ -function ensureVisibilityListener(store: Store) { - if (typeof document === "undefined" || store.visibilityListener) return; +let moduleVisibilityListener: (() => void) | null = null; + +function ensureVisibilityListener(_store: Store) { + if (typeof document === "undefined" || moduleVisibilityListener) return; const onVisibility = () => { - if (store.pollIntervalMs === undefined) return; - // Buckets are shared, so one transition re-evaluates all of them: a hidden bucket - // with no opt-out member drops its timer entirely, and a visible one re-arms. syncAllBuckets(); if (documentIsHidden()) return; - const entry = pickFetcherEntry(store); - if (!entry) return; - void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); + // Make up the ticks each polling store missed while hidden. + for (const bucket of pollBuckets.values()) { + for (const store of bucket.stores) { + const entry = pickFetcherEntry(store); + if (!entry) continue; + void runFetch(store, entry.fetcher, { replaceInflight: false, owner: entry.owner, deadlineMs: entry.deadlineMs }); + } + } }; document.addEventListener("visibilitychange", onVisibility); - store.visibilityListener = onVisibility; + moduleVisibilityListener = onVisibility; } -function removeVisibilityListener(store: Store) { - if (!store.visibilityListener) return; +/** Drop the shared listener once nothing polls at all. */ +function removeVisibilityListener(_store: Store) { + if (!moduleVisibilityListener) return; + if (pollBuckets.size > 0) return; if (typeof document !== "undefined") { - document.removeEventListener("visibilitychange", store.visibilityListener); + document.removeEventListener("visibilitychange", moduleVisibilityListener); } - store.visibilityListener = null; + moduleVisibilityListener = null; } async function runFetch( @@ -670,6 +673,12 @@ export function clearClientResourceStoresForTests(): void { if (bucket.timer !== null) clearInterval(bucket.timer); pollBuckets.delete(intervalMs); } + // The shared listener outlives individual stores, so the reset must drop it too or + // a later suite's document would keep a handler bound to the previous one. + if (moduleVisibilityListener && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", moduleVisibilityListener); + } + moduleVisibilityListener = null; } /** diff --git a/gui/src/data-surface.ts b/gui/src/data-surface.ts index 59178c0780..b682365a9e 100644 --- a/gui/src/data-surface.ts +++ b/gui/src/data-surface.ts @@ -7,6 +7,7 @@ * with per-page booleans, which is why a slow load could look identical to an empty result. */ +import { useCallback, useMemo } from "react"; import { type ResourceSnapshot, useKeyedClientResource } from "./client-resource"; import { readSessionListCacheEntry, writeSessionListCacheEntry } from "./session-list-cache"; @@ -56,6 +57,12 @@ export type DataSurfaceOptions = { * Opt-in session-cache wiring: the surface seeds from this key on mount and writes * every successful payload back with its timestamp. Pages that already own their * cache keep doing it themselves and leave this unset. + * + * Seeding alone (no `staleAfterMs`) keeps today's always-revalidate behavior: the + * cached payload paints immediately instead of a skeleton, and the live value still + * arrives. Add `staleAfterMs` only for a surface that OWNS its truth — a view that + * mirrors state another page can mutate would otherwise contradict the toggle the + * user just made, with no request in flight to correct it. */ sessionCacheKey?: string; }; @@ -158,16 +165,21 @@ export function useDataSurface( options: DataSurfaceOptions, ): DataSurfaceResource { const { isEmpty, sessionCacheKey, ...resourceOptions } = options; - // Read once per render; sessionStorage is synchronous and the seed only applies - // while the store is empty, so a repeat read costs nothing and stays in sync. - const cachedEntry = sessionCacheKey ? readSessionListCacheEntry(sessionCacheKey) : null; - const loadAndCache = sessionCacheKey - ? async (signal: AbortSignal): Promise => { + // The seed only applies while the store is empty, so reading it once per key is + // enough — and a page like the Integrations overview holds eight of these, whose + // parses would otherwise repeat on every render as each resource settles. + const cachedEntry = useMemo( + () => (sessionCacheKey ? readSessionListCacheEntry(sessionCacheKey) : null), + [sessionCacheKey], + ); + const loadAndCache = useCallback( + async (signal: AbortSignal): Promise => { const next = await load(signal); - writeSessionListCacheEntry(sessionCacheKey, next); + if (sessionCacheKey) writeSessionListCacheEntry(sessionCacheKey, next); return next; - } - : load; + }, + [load, sessionCacheKey], + ); const resource = useKeyedClientResource(key, deps, loadAndCache, { ...resourceOptions, ...(sessionCacheKey diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 5b30cbae88..4ebb97ed02 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -78,7 +78,6 @@ export default function FileIntegrationPage({ isEmpty: () => false, enabled: active, sessionCacheKey: `ocx.integrations.state.v1:${apiBase}:${client}`, - staleAfterMs: 60_000, }, ); const historyResource = useDataSurface( @@ -89,7 +88,6 @@ export default function FileIntegrationPage({ isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.client-journal.v1:${apiBase}:${client}`, - staleAfterMs: 60_000, }, ); diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 2bf34b508d..dcbf876ba1 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -216,19 +216,19 @@ export default function IntegrationsOverview({ `integration-states:${apiBase}`, [apiBase], fetchStates, - { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.states.v1:${apiBase}` }, ); const historyResource = useDataSurface( `integration-journal-all:${apiBase}`, [apiBase], fetchHistory, - { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.journal.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: rows => rows.length === 0, enabled: active, sessionCacheKey: `ocx.integrations.journal.v1:${apiBase}` }, ); const codexResource = useDataSurface( `integration-codex:${apiBase}`, [apiBase], fetchCodex, - { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.codex.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.codex.v1:${apiBase}` }, ); const keysResource = useDataSurface( `integration-keys:${apiBase}`, @@ -236,31 +236,31 @@ export default function IntegrationsOverview({ fetchKeyCount, // The loader now throws instead of resolving null, so null is not a value // it can produce. Leaving the old predicate would outlive its contract. - { isEmpty: () => false, enabled: active, sessionCacheKey: `ocx.integrations.keys.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: () => false, enabled: active, sessionCacheKey: `ocx.integrations.keys.v1:${apiBase}` }, ); const claudeResource = useDataSurface( `integration-claude:${apiBase}`, [apiBase], fetchClaude, - { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude.v1:${apiBase}` }, ); const claudeDesktopResource = useDataSurface( `integration-claude-desktop:${apiBase}`, [apiBase], fetchClaudeDesktop, - { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude-desktop.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.claude-desktop.v1:${apiBase}` }, ); const grokResource = useDataSurface( `integration-grok:${apiBase}`, [apiBase], fetchGrok, - { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.grok.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.grok.v1:${apiBase}` }, ); const nativeResource = useDataSurface( `integration-native:${apiBase}`, [apiBase], fetchNative, - { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.native.v1:${apiBase}`, staleAfterMs: 60_000 }, + { isEmpty: value => value === null, enabled: active, sessionCacheKey: `ocx.integrations.native.v1:${apiBase}` }, ); const clients = statesResource.state.data ?? []; diff --git a/gui/tests/integrations-cache-freshness.test.ts b/gui/tests/integrations-cache-freshness.test.ts new file mode 100644 index 0000000000..1dc5a19866 --- /dev/null +++ b/gui/tests/integrations-cache-freshness.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +/** + * The Integrations surfaces MIRROR state that other pages own and mutate: the overview + * card and the per-client page describe the same connection through different cache + * keys. Giving them a staleness window meant a toggle on one could be contradicted by + * a cached read on the other for up to a minute, with no request in flight to correct + * it — a revisit is exactly when the user looks. They may seed from cache (so a revisit + * paints instead of flashing a skeleton) but must always revalidate. + */ +const MIRROR_SURFACES = [ + "src/pages/integrations/IntegrationsOverview.tsx", + "src/pages/integrations/FileIntegrationPage.tsx", +]; + +test("integration mirror surfaces seed from cache but never suppress revalidation", () => { + for (const path of MIRROR_SURFACES) { + const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + expect(source).toContain("sessionCacheKey"); + expect(source).not.toContain("staleAfterMs"); + } +}); + +/** + * The pages that OWN their truth keep the window: nothing else writes their state + * behind their back, so a revisit inside it is genuinely up to date. + */ +test("self-owned surfaces keep their staleness window", () => { + for (const path of ["src/pages/Combos.tsx", "src/pages/ApiKeys.tsx", "src/pages/Grok.tsx"]) { + const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + expect(source).toContain("staleAfterMs"); + } +}); From 17cfdcc421bc6cf3d6996e488030748e9029f8c2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 02:23:48 +0900 Subject: [PATCH 4/4] docs(devlog): record the stack delivery, rebase history, and CI flakes (050) --- .../050_delivery_record.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 devlog/_plan/260816_gui_loading_performance/050_delivery_record.md diff --git a/devlog/_plan/260816_gui_loading_performance/050_delivery_record.md b/devlog/_plan/260816_gui_loading_performance/050_delivery_record.md new file mode 100644 index 0000000000..065ddb8df3 --- /dev/null +++ b/devlog/_plan/260816_gui_loading_performance/050_delivery_record.md @@ -0,0 +1,79 @@ +# 050 — WP5: delivery record (stack publication, CI, remote suite, merge) + +## Stack shape + +Four dependency-ordered layers, each PR based on the one below, bottom targeting +`dev`: + +| # | PR | Branch | Layer | +|---|----|--------|-------| +| 1 | #1854 | codex/gui-resource-deadline | resource deadline | +| 2 | #1855 | codex/gui-auth-unwedge | 401 re-bootstrap unwedge | +| 3 | #1856 | codex/gui-hidden-pause | hidden tab = zero timers | +| 4 | #1857 | codex/gui-poll-consolidation | shared scheduler + revisit freshness | + +## Rebase history + +Two cascades, both `git rebase --update-refs --onto origin/dev ` from +the top branch so all four refs move in one pass: + +1. The stack was cut at `b81314cd2`; `origin/dev` had advanced 88 commits. +2. A second cascade onto `8f7a22ff7` picked up PR #1853, which had landed the + admission-`source` contract. That one mattered: the remote suite had reported + 2 failures in `tests/loopback-listener-admission.test.ts` that were NOT ours — + the remote checkout paired #1853's NEW test file with our older source. After + the cascade the same suite ran clean, which is what confirmed the diagnosis. + +A comment-text-only conflict in `gui/src/visibility-poll.ts` was resolved in +favor of the WP3 wording (see below). + +## Layer independence correction + +CI caught what local runs could not: WP3 (#1856) failed its own `gates` job with +nine failures in the codex-auto-switch and account-picker suites. Those tests +intercept `window.setInterval`, and `startVisibilityPoll` was scheduling through +the bare global — the fix existed only in the WP4 commit. Since DEV-STACK-03 +requires every layer to be green at its own tip, the fix moved DOWN into WP3 +(`116bcae03`, later `c75f88d6c`) where the migration itself lives. + +Verified after the move: WP3 tip 913 pass / 0 fail, WP4 tip 924 pass / 0 fail. + +## Verification evidence + +- Remote full suite (`ssh lidge`, `bun test --isolate tests`) on the WP4 tip + after the second cascade: **12700 pass / 0 fail / 15 skip, EXIT=0**. + A `dev`-baseline run in the same session also finished EXIT=0, which is how the + earlier 2 failures were attributed to the stale base rather than to this work. +- Local: `bun run typecheck` green; `cd gui && bun test tests` 924 pass; + `bun run lint`, `bun run lint:i18n`, `bun run build` green. +- Browser: dashboard renders unchanged; revisit inside the freshness window + issues zero requests with zero skeletons. + +## CI flakes encountered (not regressions) + +The macOS and `test 2/4` jobs each failed once with a **Bun runtime crash** +(exit 133 / exit 132, `storage-worker-lifecycle` under singleton isolation) — +the workflow itself distinguishes these from assertion failures and retries once +before giving up. Assertion count in both logs was `0 fail`. Re-runs cleared +them, and the same commits pass the full suite on Linux. + +The `ci` aggregate job also failed on several branches by asserting before the +long macOS job had reported; re-running it after macOS completes is the fix. + +## Screenshot gate + +`enforce-target` requires a UI screenshot for any PR touching `gui/`. These four +change request lifecycle, timers, and caching with no visual delta, so the +maintainer waiver label `gui-screenshot-waived` was applied to all four, and a +post-change dashboard screenshot was captured during verification. + +## Merge + +Bottom-up, one at a time, each with `--match-head-commit` bound to the audited +SHA. `dev` requires one approval and the author cannot self-approve, so the +project owner's admin merge (pre-authorized by the user for this campaign) is +the path used. + +- #1854 merged at 2026-08-16T17:15:18Z as `e2ef24ad6`. +- Remaining layers merge in order once their own checks are green, retargeting + each child to `dev` after its parent lands.