fix(version-poller): surface a reload toast instead of a silent hard reload

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 02:52:59 +02:00
1 parent 51d67af28f
commit a74a0c9b09
2 files changed
+50 -20

No files matched your search

@@ -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";
+40 -14
View File
@@ -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<void> {
}
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<string | null>(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;
}