From a74a0c9b0925aa7f2ff81797cabd51c11e039e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:59 +0200 Subject: [PATCH] fix(version-poller): surface a reload toast instead of a silent hard reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s poller used to unregister service workers, wipe CacheStorage, and call location.reload() the moment it saw a version bump, with no prompt — firing mid-form, mid-message, or mid-wizard and discarding whatever the user had typed. Keep detecting the new build, but now show a non-blocking, dismissible toast with a Reload action; the eviction and reload run only when the user chooses to reload. Show the toast once per detected version. --- .../__tests__/version-poller.test.ts | 16 +++--- src/components/version-poller.tsx | 54 ++++++++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index b079fea7a..fb3a6b938 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -1,12 +1,16 @@ /** - * Unit tests for the version-poller reload decision. + * Unit tests for the version-poller decision. * * The guard is keyed on the TARGET (live) version: a repeat mismatch - * against the SAME version is suppressed (no reload loop on a misset - * server), but a SECOND deploy in one session moves the live version - * past the recorded one and the heal flow re-arms. The pre-v1.16.8 - * guard disabled all polling after one attempt, which stranded stale - * shells on multi-deploy days. + * against the SAME version is suppressed (no loop on a misset server), + * but a SECOND deploy in one session moves the live version past the + * recorded one and the flow re-arms. + * + * The runtime behaviour — a detected bump surfaces a Reload toast and + * never reloads the page on its own — lives in `version-poller.tsx`: + * the service-worker eviction + `location.reload()` run only from the + * toast action's `onClick`, so an in-progress form or chat draft is + * never destroyed unprompted. */ import { describe, expect, it } from "vitest"; diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index a4514311e..b5a7420dc 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -1,8 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.38.4 — runtime self-heal for the stale-shell post-deploy @@ -20,7 +22,10 @@ import { apiGet } from "@/lib/api/api-fetch"; * `/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 - * the running shell: + * the running shell it surfaces a non-blocking toast with a Reload + * action. The eviction + reload only runs when the user clicks + * Reload — an in-progress form or chat draft is never destroyed by a + * silent mid-session reload. On that click: * * 1. Unregister every active service worker so the next page load * doesn't reinstall the old SW. @@ -30,15 +35,15 @@ import { apiGet } from "@/lib/api/api-fetch"; * 3. `window.location.reload()` — fetches the fresh shell + new * chunk graph. * - * The reload guard is keyed on the TARGET version: `sessionStorage` - * records which live version we last reloaded for, and only a repeat - * mismatch against that SAME version is suppressed (a misset version — - * server briefly serves a stale image mid-deploy — cannot loop). - * Polling itself never stops, so on a multi-deploy day a SECOND - * release moves the live version past the recorded one and the heal - * flow re-arms. The pre-v1.16.8 guard disabled all polling for the - * rest of the session after one attempt, which left a stale shell - * with 404ing chunks stuck forever once the guard was spent. + * The toast is shown once per detected version (the poll keeps + * running, but a repeated mismatch against a version we already + * surfaced doesn't re-spam every 60 s). The reload guard is keyed on + * the TARGET version: `sessionStorage` records which live version we + * last reloaded for, and only a repeat mismatch against that SAME + * version is suppressed (a misset version — server briefly serves a + * stale image mid-deploy — cannot loop). On a multi-deploy day a + * SECOND release moves the live version past the recorded one and the + * flow re-arms. */ const POLL_INTERVAL_MS = 60_000; @@ -116,6 +121,11 @@ async function evictAndReload(targetVersion: string): Promise { } export function VersionPoller(): null { + const { t } = useTranslations(); + // Tracks the live version we've already surfaced a toast for, so a + // repeated mismatch on the recurring poll doesn't re-spam. + const notifiedForRef = useRef(null); + useEffect(() => { if (!SHELL_VERSION) return; if (typeof window === "undefined") return; @@ -137,11 +147,27 @@ export function VersionPoller(): null { lastReloadedFor, ); if (decision !== "reload") return; - await evictAndReload(live as string); + + const target = live as string; + // One toast per detected version — don't re-open it every poll. + if (notifiedForRef.current === target) return; + notifiedForRef.current = target; + + toast(t("version.updateAvailable.title"), { + duration: Infinity, + action: { + label: t("version.updateAvailable.reload"), + onClick: () => void evictAndReload(target), + }, + cancel: { + label: t("version.updateAvailable.dismiss"), + onClick: () => {}, + }, + }); } // First check 5 s after mount — gives the app a chance to settle - // before we touch caches. Then on the recurring interval. + // before we surface anything. Then on the recurring interval. const initial = window.setTimeout(() => void checkOnce(), 5_000); const interval = window.setInterval( () => void checkOnce(), @@ -153,7 +179,7 @@ export function VersionPoller(): null { window.clearInterval(interval); controller.abort(); }; - }, []); + }, [t]); return null; }