From b78a469eb2532fafc5f4c85f10df098d1a3e12a3 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 11 Aug 2026 11:19:36 -0600 Subject: [PATCH] fix(desktop): suppress fresh focus-return query herd Gate the seven query families measured refetching together on focus so only results older than five minutes catch up. Keep their normal stale times, focused polling, and live invalidation behavior unchanged. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/features/agents/hooks.ts | 5 +- .../agents/lib/usePersonaCatalogRelay.ts | 7 +- .../src/features/channel-templates/hooks.ts | 7 +- desktop/src/features/custom-emoji/hooks.ts | 7 +- desktop/src/features/presence/hooks.ts | 7 +- desktop/src/features/user-status/hooks.ts | 7 +- .../src/shared/lib/focusReturnPolicy.test.mjs | 126 ++++++++++++++++++ desktop/src/shared/lib/useDocumentVisible.ts | 18 +++ 8 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 desktop/src/shared/lib/focusReturnPolicy.test.mjs diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 7cc93b7b61..bb112f103d 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -19,6 +19,7 @@ import { import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { + refetchOnFocusWhenOld, useAppFocused, useFocusedRefetchInterval, } from "@/shared/lib/useDocumentVisible"; @@ -340,7 +341,7 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { // reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we // keep polling but at a relaxed cadence and pause it while backgrounded. refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, enabled: options?.enabled, }); } @@ -364,7 +365,7 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { ? 5_000 : false; }, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 9b0b669ba5..838e6e92d9 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -7,7 +7,10 @@ import { } from "@/features/agents/lib/personaCatalogRelay"; import { invalidatePersonaEditCaches } from "@/features/agents/lib/personaEditCaches"; import { relayClient } from "@/shared/api/relayClient"; -import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; +import { + refetchOnFocusWhenOld, + useFocusedRefetchInterval, +} from "@/shared/lib/useDocumentVisible"; import { setPersonaShared, updatePersonaAndPublish, @@ -27,7 +30,7 @@ export function usePersonaCatalogQuery(communityId: string | null) { queryFn: fetchPersonaCatalogPublications, staleTime: 30_000, refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/features/channel-templates/hooks.ts b/desktop/src/features/channel-templates/hooks.ts index 412fb0eef7..fb8c2c660e 100644 --- a/desktop/src/features/channel-templates/hooks.ts +++ b/desktop/src/features/channel-templates/hooks.ts @@ -1,6 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; +import { + refetchOnFocusWhenOld, + useFocusedRefetchInterval, +} from "@/shared/lib/useDocumentVisible"; import { createChannelTemplate, @@ -25,7 +28,7 @@ export function useChannelTemplatesQuery() { queryFn: listChannelTemplates, staleTime: 30_000, refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/features/custom-emoji/hooks.ts b/desktop/src/features/custom-emoji/hooks.ts index 18ba206795..f85fa0d795 100644 --- a/desktop/src/features/custom-emoji/hooks.ts +++ b/desktop/src/features/custom-emoji/hooks.ts @@ -10,7 +10,10 @@ import { setCustomEmoji, } from "@/shared/api/customEmoji"; import { relayClient } from "@/shared/api/relayClient"; -import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; +import { + refetchOnFocusWhenOld, + useFocusedRefetchInterval, +} from "@/shared/lib/useDocumentVisible"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; /** @@ -38,7 +41,7 @@ export function useCustomEmojiQuery() { // but poll every 2 minutes as a backstop for any missed live event. staleTime: 60_000, refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index 1d2df1ed22..faee521274 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -7,7 +7,10 @@ import { useRelayConnection } from "@/shared/api/useRelayConnection"; import { getOsIdleSeconds } from "@/shared/api/osIdle"; import { getPresence } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; +import { + refetchOnFocusWhenOld, + useFocusedRefetchInterval, +} from "@/shared/lib/useDocumentVisible"; import { mergePresenceUpdate, parseLivePresenceEvent, @@ -95,7 +98,7 @@ export function usePresenceQuery( // (crashed clients). WS events handle the fast path. Pause on degraded // connections — HTTP presence calls fail anyway and consume relay quota. refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/features/user-status/hooks.ts b/desktop/src/features/user-status/hooks.ts index eaa6ac2b18..f3fa0d9623 100644 --- a/desktop/src/features/user-status/hooks.ts +++ b/desktop/src/features/user-status/hooks.ts @@ -8,7 +8,10 @@ import type { UserStatusLookup, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; +import { + refetchOnFocusWhenOld, + useFocusedRefetchInterval, +} from "@/shared/lib/useDocumentVisible"; import { KIND_USER_STATUS } from "@/shared/constants/kinds"; function normalizePubkeys(pubkeys: string[]) { @@ -78,7 +81,7 @@ export function useUserStatusQuery(pubkeys: string[]) { }, staleTime: 60_000, refetchInterval, - refetchOnWindowFocus: true, + refetchOnWindowFocus: refetchOnFocusWhenOld, }); } diff --git a/desktop/src/shared/lib/focusReturnPolicy.test.mjs b/desktop/src/shared/lib/focusReturnPolicy.test.mjs new file mode 100644 index 0000000000..33ff175851 --- /dev/null +++ b/desktop/src/shared/lib/focusReturnPolicy.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { + focusManager, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; + +import { + FOCUS_RETURN_STALE_TIME_MS, + refetchOnFocusWhenOld, + shouldRefetchOnFocus, +} from "./useDocumentVisible.ts"; + +const NOW = 1_800_000_000_000; + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ + ageMs, + status = "success", + dataUpdatedAt = Date.now() - ageMs, +}) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-return-policy", ageMs, status, dataUpdatedAt]; + queryClient.setQueryData(queryKey, "cached", { updatedAt: dataUpdatedAt }); + if (status !== "success") { + queryClient.getQueryCache().find({ queryKey }).setState({ status }); + } + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + refetchOnWindowFocus: refetchOnFocusWhenOld, + staleTime: 5_000, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("focus return keeps recently fetched stale queries cached", async () => { + assert.equal( + await focusRefetchCount({ ageMs: FOCUS_RETURN_STALE_TIME_MS - 1_000 }), + 0, + ); +}); + +test("focus return catches up genuinely old queries", async () => { + assert.equal( + await focusRefetchCount({ ageMs: FOCUS_RETURN_STALE_TIME_MS + 1 }), + 1, + ); +}); + +test("focus return fetches a missing result", () => { + assert.equal( + shouldRefetchOnFocus({ status: "pending", dataUpdatedAt: 0 }, NOW), + "always", + ); +}); + +test("focus return retries an error with recent retained data", async () => { + assert.equal( + shouldRefetchOnFocus({ status: "error", dataUpdatedAt: NOW - 10_000 }, NOW), + "always", + ); + assert.equal(await focusRefetchCount({ ageMs: 10_000, status: "error" }), 1); +}); + +test("focus return fetches after the clock moves backward", async () => { + assert.equal( + shouldRefetchOnFocus( + { status: "success", dataUpdatedAt: NOW + 1_000 }, + NOW, + ), + "always", + ); + assert.equal( + await focusRefetchCount({ + ageMs: -1_000, + dataUpdatedAt: Date.now() + 1_000, + }), + 1, + ); +}); + +test("focus return age-gates successful results at the exact boundary", () => { + assert.equal( + shouldRefetchOnFocus( + { + status: "success", + dataUpdatedAt: NOW - FOCUS_RETURN_STALE_TIME_MS + 1, + }, + NOW, + ), + false, + ); + assert.equal( + shouldRefetchOnFocus( + { + status: "success", + dataUpdatedAt: NOW - FOCUS_RETURN_STALE_TIME_MS, + }, + NOW, + ), + true, + ); +}); diff --git a/desktop/src/shared/lib/useDocumentVisible.ts b/desktop/src/shared/lib/useDocumentVisible.ts index 3368e5bde7..8b5d4458e0 100644 --- a/desktop/src/shared/lib/useDocumentVisible.ts +++ b/desktop/src/shared/lib/useDocumentVisible.ts @@ -1,5 +1,23 @@ import * as React from "react"; +export const FOCUS_RETURN_STALE_TIME_MS = 5 * 60_000; + +export function shouldRefetchOnFocus( + state: { dataUpdatedAt: number; status: string }, + now = Date.now(), +): boolean | "always" { + if (state.status !== "success" || state.dataUpdatedAt <= 0) return "always"; + const ageMs = now - state.dataUpdatedAt; + if (ageMs < 0) return "always"; + return ageMs >= FOCUS_RETURN_STALE_TIME_MS; +} + +export function refetchOnFocusWhenOld(query: { + state: { dataUpdatedAt: number; status: string }; +}): boolean | "always" { + return shouldRefetchOnFocus(query.state); +} + export function isDocumentVisible(): boolean { if (typeof document === "undefined") return true; return document.visibilityState === "visible";