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.