From 1844e58b5ab87715ca9107cca248575d6b414089 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Fri, 21 Aug 2026 22:37:31 +0200 Subject: [PATCH] Gate the default focus refetch on lost realtime coverage Every window focus refetched every active query older than 2s. On a phone that meant each unlock and app switch fired a full refetch wave on top of the realtime reconnect wave: the WebSocket manager already probes the socket on visible, the reconnect watermark refetches exactly the queries whose data predates the disconnect, and changes merged while hidden flush on the next visible. The focus wave duplicated all of that in the first interactive frames after unlock. Make the default focus refetch injectable and gate it in main on wsManager's connection state: while the state is "connected", realtime owns freshness; in "connecting" or "reconnecting" the focus refetch remains the fallback. Per-query refetchOnWindowFocus policies (query-policies.ts) are unaffected and still win. Known narrow trade, called out for review: a half-open socket reports "connected" for up to the 5s pong timeout, and the handful of queries with neither realtime coverage nor a focus policy (e.g. CLI skills status) skip one focus wave when parked across an unlock; refetchOnMount still repairs them on navigation. Co-Authored-By: Claude Fable 5 --- apps/app/src/lib/query-client.test.ts | 77 +++++++++++++++++++++++++++ apps/app/src/lib/query-client.ts | 17 +++++- apps/app/src/main.tsx | 9 +++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/query-client.test.ts b/apps/app/src/lib/query-client.test.ts index 57adcac03e..66b1b187da 100644 --- a/apps/app/src/lib/query-client.test.ts +++ b/apps/app/src/lib/query-client.test.ts @@ -117,6 +117,83 @@ describe("createAppQueryClient", () => { queryClient.clear(); }); + it("keeps the default focus refetch when no gate is configured", async () => { + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + showMutationErrorToasts: false, + }); + queryClient.mount(); + + const queryFn = vi.fn(() => Promise.resolve("data")); + const observer = new QueryObserver(queryClient, { + queryKey: ["focus-ungated"], + queryFn, + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + + await vi.waitFor(() => { + expect(observer.getCurrentResult().data).toBe("data"); + }); + + window.dispatchEvent(new Event("pageshow")); + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2); + }); + + unsubscribe(); + queryClient.unmount(); + queryClient.clear(); + }); + + it("skips the default focus refetch while the gate reports realtime coverage", async () => { + let realtimeConnected = true; + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + shouldRefetchOnWindowFocus: () => !realtimeConnected, + showMutationErrorToasts: false, + }); + queryClient.mount(); + + const queryFn = vi.fn(() => Promise.resolve("data")); + const observer = new QueryObserver(queryClient, { + queryKey: ["focus-gated"], + queryFn, + // Instantly stale so a permitted focus refetch always fires. + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + + await vi.waitFor(() => { + expect(observer.getCurrentResult().data).toBe("data"); + }); + expect(queryFn).toHaveBeenCalledTimes(1); + + // Connected: realtime owns freshness, focus must not refetch. + window.dispatchEvent(new Event("pageshow")); + await Promise.resolve(); + expect(queryFn).toHaveBeenCalledTimes(1); + + // Coverage lost: focus refetch is the fallback again. + realtimeConnected = false; + window.dispatchEvent(new Event("pageshow")); + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2); + }); + + unsubscribe(); + queryClient.unmount(); + queryClient.clear(); + }); + it("resumes a suspend-cancelled fetch that no focus refetch would restart", async () => { const queryClient = createAppQueryClient({ defaultOptions: { diff --git a/apps/app/src/lib/query-client.ts b/apps/app/src/lib/query-client.ts index 0b731ab3fc..5f7c905576 100644 --- a/apps/app/src/lib/query-client.ts +++ b/apps/app/src/lib/query-client.ts @@ -17,6 +17,17 @@ import { interface CreateAppQueryClientOptions { defaultOptions?: QueryClientConfig["defaultOptions"]; showMutationErrorToasts?: boolean; + /** + * Gate for the default focus refetch. Focus refetch is the freshness + * fallback for when realtime coverage is lost; while the socket is + * connected, change events keep the cache correct and the reconnect + * watermark repairs any gap, so a focus event (every phone unlock and + * app switch) must not refetch every active query on top of that wave. + * Defaults to always refetching. A `defaultOptions.queries.refetchOnWindowFocus` + * passed alongside this gate wins over it (caller defaults are spread last), + * so pass one or the other. + */ + shouldRefetchOnWindowFocus?: () => boolean; } interface AppQueryClientBrowserEventCleanup { @@ -108,6 +119,7 @@ export function createAppQueryClient( const defaultOptions = options.defaultOptions; const showMutationErrorToasts = options.showMutationErrorToasts ?? true; + const shouldRefetchOnWindowFocus = options.shouldRefetchOnWindowFocus; return new QueryClient({ mutationCache: new MutationCache({ @@ -133,7 +145,10 @@ export function createAppQueryClient( ...defaultOptions, queries: { staleTime: 2000, - refetchOnWindowFocus: true, + refetchOnWindowFocus: + shouldRefetchOnWindowFocus === undefined + ? true + : () => shouldRefetchOnWindowFocus(), retry: shouldRetryTransientReadQuery, retryDelay: TRANSIENT_READ_RETRY_DELAY_MS, ...defaultOptions?.queries, diff --git a/apps/app/src/main.tsx b/apps/app/src/main.tsx index 1eba2c5ec6..4d1c8fd865 100644 --- a/apps/app/src/main.tsx +++ b/apps/app/src/main.tsx @@ -15,6 +15,7 @@ import { installAppQueryClientBrowserEvents, } from "./lib/query-client"; import { applyCachedAppThemeCss } from "./lib/themes"; +import { wsManager } from "./lib/ws"; import "./app.css"; // Before anything renders: a content script that moves a React-owned node out @@ -28,7 +29,13 @@ installForeignDomMutationGuard(); // costs anything when an Error is actually constructed. Error.stackTraceLimit = 50; -const queryClient = createAppQueryClient(); +const queryClient = createAppQueryClient({ + // While the realtime socket is connected, change events and the reconnect + // watermark own cache freshness; a focus refetch on top would re-request + // every active query on each phone unlock and app switch. + shouldRefetchOnWindowFocus: () => + wsManager.getConnectionState() !== "connected", +}); installAppQueryClientBrowserEvents(queryClient); // The provider CLI install store outlives every component, so it takes the // client here rather than reading it from context when an install finishes.