feat(app): self-heal the stale-shell post-deploy paper-cut

The v1.4.38.3 `AppError` boundary reloads once when a stale shell
trips `ChunkLoadError`, but the reload only fires AFTER the user
hits a navigation that lazy-loads a missing chunk. Users sitting on
the dashboard or insights overview ride out the staleness until
their next click; some never click and the cached shell silently
drifts further out of date with every deploy.

Three layered fixes so the running app heals itself instead:

- `public/sw.js` now keys both `STATIC_CACHE` and `PAGE_CACHE` to
  the app release tag (`v1.4.38.4`). The `activate` step already
  evicts any cache name outside the current pair, so bumping the
  string at release time guarantees the precached root HTML and the
  precached `/_next/static/*` chunks from the previous deploy are
  dropped on the next SW install.
- `next.config.ts` injects `NEXT_PUBLIC_APP_VERSION` from
  `package.json` into the client bundle so the running shell knows
  which release it was built against.
- New `<VersionPoller>` client component mounts inside `<Providers>`
  and polls `/api/version` every 60 s. If the live version moves
  ahead of the shell-baked version it unregisters every active SW,
  wipes CacheStorage, and `window.location.reload()`s.
  `sessionStorage` gates the reload to once per session so a
  mid-deploy webhook race that briefly returns a stale image cannot
  loop the page.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 05:49:24 +02:00
1 parent a16bb4b753
commit 34a8f081ef
4 files changed
+147 -1

No files matched your search

+11
View File
@@ -1,9 +1,20 @@
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import { version as PKG_VERSION } from "./package.json";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
// v1.4.38.4 — expose the package.json version to the client bundle
// so the `<VersionPoller>` can compare the shell-baked version
// against the live `/api/version` response and trigger a self-heal
// (SW unregister + cache wipe + hard reload) when the server moves
// ahead of the running shell after a deploy. Without this the user
// had to discover "pull-to-refresh" themselves after every release.
env: {
NEXT_PUBLIC_APP_VERSION: PKG_VERSION,
},
// v1.4.33 IW2 — strip `console.*` calls from the production bundle
// (keep `console.error` + `console.warn` so the GlitchTip reporter
// and prod-side debug rails still surface). The Lighthouse audit
+11 -1
View File
@@ -3,7 +3,17 @@
* Handles Web Push notifications and offline caching.
*/
const CACHE_VERSION = "v1";
// v1.4.38.4 — `CACHE_VERSION` now tracks the app release tag.
// Bumped on every release so the `activate` step evicts every
// pre-release cache entry. Without this the precached root HTML and
// the cached `/_next/static/*` chunks survived across deploys,
// stale-shell-served the old chunk graph, and the running app then
// hit `ChunkLoadError` on the first lazy navigation. Pair with the
// `<VersionPoller>` client component (mounted in `<Providers>`) that
// polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`,
// and triggers an SW-unregister + cache-wipe + hard reload when the
// server moves ahead of the running shell.
const CACHE_VERSION = "v1.4.38.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
+2
View File
@@ -13,6 +13,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
// ── Theme Context ────────────────────────────────────
@@ -130,6 +131,7 @@ export function Providers({
<AppSettingsProvider>
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
</AppSettingsProvider>
</I18nProvider>
</ThemeProvider>
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useEffect } from "react";
/**
* v1.4.38.4 — runtime self-heal for the stale-shell post-deploy
* paper-cut.
*
* After a release the cached SPA shell still references the old
* chunk filenames; the running React tree lazy-loads them, the new
* server 404s them, and the user lands on `ChunkLoadError`. The
* v1.4.38.3 `AppError` boundary added a one-shot
* `window.location.reload()` for that case, but it only fires AFTER
* 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
* `/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:
*
* 1. Unregister every active service worker so the next page load
* doesn't reinstall the old SW.
* 2. Delete every CacheStorage entry so the precached root HTML
* and the `/_next/static/*` chunks from the previous deploy
* can't be served back.
* 3. `window.location.reload()` — fetches the fresh shell + new
* chunk graph.
*
* `sessionStorage` gates the reload to once per session so a misset
* version (server briefly serves a stale image, e.g. mid-deploy
* webhook race) cannot loop.
*/
const POLL_INTERVAL_MS = 60_000;
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
type VersionEnvelope = {
data?: { version?: string };
};
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
try {
const res = await fetch("/api/version", {
cache: "no-store",
signal,
});
if (!res.ok) return null;
const json = (await res.json()) as VersionEnvelope;
return json.data?.version ?? null;
} catch {
return null;
}
}
async function evictAndReload(): Promise<void> {
try {
sessionStorage.setItem(SESSION_GUARD_KEY, String(Date.now()));
} catch {
// sessionStorage can throw under strict-privacy modes; fall
// through to the reload anyway — worst case the next deploy
// re-triggers the same heal flow.
}
if ("serviceWorker" in navigator) {
try {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
} catch {
/* best effort */
}
}
if (typeof caches !== "undefined") {
try {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
} catch {
/* best effort */
}
}
window.location.reload();
}
export function VersionPoller(): null {
useEffect(() => {
if (!SHELL_VERSION) return;
if (typeof window === "undefined") return;
try {
if (sessionStorage.getItem(SESSION_GUARD_KEY)) return;
} catch {
/* fall through — we'll try the reload anyway if mismatch hits */
}
const controller = new AbortController();
async function checkOnce(): Promise<void> {
const live = await fetchLiveVersion(controller.signal);
if (!live) return;
if (live === SHELL_VERSION) return;
await evictAndReload();
}
// First check 5 s after mount — gives the app a chance to settle
// before we touch caches. Then on the recurring interval.
const initial = window.setTimeout(() => void checkOnce(), 5_000);
const interval = window.setInterval(
() => void checkOnce(),
POLL_INTERVAL_MS,
);
return () => {
window.clearTimeout(initial);
window.clearInterval(interval);
controller.abort();
};
}, []);
return null;
}