From a6fdd48058626aa64d1e112817c5dca1cc51d3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:47:24 +0100 Subject: [PATCH] fix(pwa): pause the version check while the tab is hidden The version poller asked `/api/version` every 60 s from a plain timer, also in a background tab or a minimised window, for as long as the browser let timers run. It was the largest standing poll and the only one that kept going while hidden, although the poll notes describe it as the same shape as the others. A tick now asks only while the tab is in view, and a tab that comes back after a full interval away checks once at once, so a release still surfaces on return. --- .../__tests__/version-poller.test.ts | 23 +++++++++- src/components/version-poller.tsx | 45 ++++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) 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]);