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";