diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index 14b0a24da..c9a25defa 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -15,7 +15,11 @@ import { describe, expect, it } from "vitest"; -import { cachesToEvict, resolveVersionPollDecision } from "../version-poller"; +import { + cachesToEvict, + resolveVersionPollDecision, + versionCheckDueOnReturn, +} from "../version-poller"; describe("resolveVersionPollDecision", () => { it("is up-to-date when live matches the shell", () => { @@ -172,3 +176,20 @@ describe("cachesToEvict", () => { expect(cachesToEvict(keys, "1.43.0")).not.toContain("another-app-cache"); }); }); + +describe("versionCheckDueOnReturn", () => { + const NOW = 1_000_000_000; + + it("asks at once when the tab comes back after a full interval away", () => { + expect(versionCheckDueOnReturn("visible", NOW - 60_000, NOW)).toBe(true); + expect(versionCheckDueOnReturn("visible", 0, NOW)).toBe(true); + }); + + it("does not ask again on a quick switch back", () => { + expect(versionCheckDueOnReturn("visible", NOW - 5_000, NOW)).toBe(false); + }); + + it("never asks while the tab is hidden", () => { + expect(versionCheckDueOnReturn("hidden", 0, NOW)).toBe(false); + }); +}); diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index 6252509fb..3d0527b4c 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -19,7 +19,8 @@ import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; * the user trips the error — typically in the middle of a * navigation flow. * - * This component runs the check pro-actively. Every 60 s it fetches + * This component runs the check pro-actively. Every 60 s while the tab + * is in view (and once on coming back to it) it fetches * `/api/version` and compares the live version string against * `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build * time by `next.config.ts`). When the live version moves ahead of @@ -93,6 +94,19 @@ async function fetchLiveVersion(signal: AbortSignal): Promise { } } +/** + * Whether a tab that comes back into view checks the version now. A hidden + * tab skips its ticks, so the first look after a full interval away is due at + * once; a quick switch back and forth is not a reason to ask again. + */ +export function versionCheckDueOnReturn( + visibility: DocumentVisibilityState, + lastCheckedAt: number, + now: number, +): boolean { + return visibility === "visible" && now - lastCheckedAt >= POLL_INTERVAL_MS; +} + // In-memory mirror of the session guard: under strict-privacy modes // sessionStorage throws, and without a fallback a persistent // shell/live mismatch (misconfigured build) would reload every poll. @@ -165,8 +179,10 @@ export function VersionPoller(): null { if (typeof window === "undefined") return; const controller = new AbortController(); + let lastCheckedAt = 0; async function checkOnce(): Promise { + lastCheckedAt = Date.now(); const live = await fetchLiveVersion(controller.signal); let lastReloadedFor: string | null = null; try { @@ -201,16 +217,31 @@ export function VersionPoller(): null { } // First check 5 s after mount — gives the app a chance to settle - // before we surface anything. Then on the recurring interval. - const initial = window.setTimeout(() => void checkOnce(), 5_000); - const interval = window.setInterval( - () => void checkOnce(), - POLL_INTERVAL_MS, - ); + // before we surface anything. Then on the recurring interval, but only + // while the tab is in view: a background tab asks nothing and checks + // once when it comes back. + const tick = () => { + if (document.visibilityState === "visible") void checkOnce(); + }; + const onVisibilityChange = () => { + if ( + versionCheckDueOnReturn( + document.visibilityState, + lastCheckedAt, + Date.now(), + ) + ) { + void checkOnce(); + } + }; + const initial = window.setTimeout(tick, 5_000); + const interval = window.setInterval(tick, POLL_INTERVAL_MS); + document.addEventListener("visibilitychange", onVisibilityChange); return () => { window.clearTimeout(initial); window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); controller.abort(); }; }, [t]);