From 34a8f081ef756759c715c17e35ec459784d3171e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 18 May 2026 05:49:24 +0200 Subject: [PATCH 1/3] 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 `` client component mounts inside `` 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. --- next.config.ts | 11 +++ public/sw.js | 12 ++- src/components/providers.tsx | 2 + src/components/version-poller.tsx | 123 ++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/components/version-poller.tsx diff --git a/next.config.ts b/next.config.ts index 2f931b035..c37098a14 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 `` 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 diff --git a/public/sw.js b/public/sw.js index a2d42e2cb..e8331fdc3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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 +// `` client component (mounted in ``) 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; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index f9d342150..efe789ca6 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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({ {children} + diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx new file mode 100644 index 000000000..bb8251390 --- /dev/null +++ b/src/components/version-poller.tsx @@ -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 { + 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 { + 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 { + 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; +} From d0f8084fb2615fd4ebc3e456ccf323323b75e09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 18 May 2026 05:49:49 +0200 Subject: [PATCH 2/3] chore(release): v1.4.38.4 Self-healing stale-shell after every deploy. New `` client component proactively polls `/api/version` and triggers an SW-unregister + cache wipe + hard reload when the server moves ahead of the running shell. SW `CACHE_VERSION` now keys to the release tag so the precached chunk graph from the previous deploy is dropped on next SW install. See CHANGELOG.md. --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb104d60f..1ffe8778d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.4.38.4] — 2026-05-18 — Self-healing stale-shell after every deploy + +v1.4.38.3 closed the stale-shell case where the user trips +`ChunkLoadError` mid-navigation — but the reload only fired AFTER +the error surfaced. Users sitting on the dashboard or insights +overview rode out the staleness until they clicked something. The +PWA shell could silently drift across several deploys, surfacing +nothing visible to the user until a click happened to lazy-load a +missing chunk. + +### Added + +- **`` client component** mounted inside + ``. Polls `/api/version` every 60 s. If the live + version moves ahead of the shell-baked version it unregisters + every active service worker, wipes CacheStorage, and triggers + `window.location.reload()`. `sessionStorage` gates the reload to + once per session so a mid-deploy webhook race that briefly serves + a stale image cannot loop the page. +- **`NEXT_PUBLIC_APP_VERSION`** is now injected from `package.json` + at build time (via `next.config.ts`) so the running shell knows + which release it was built against. Available to any client + component that needs to compare against the live server version. + +### Changed + +- **Service worker `CACHE_VERSION` now keys to the 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. The string is now part of the release + routine alongside the `package.json` bump. + +### Operator notes + +- No new migration. No env-var change. +- Coolify auto-deploys main on tag push. +- Users on the pre-v1.4.38.4 shell still need a single manual + refresh to pick up the new poller; from v1.4.38.4 onwards every + future release self-heals. + ## [1.4.38.3] — 2026-05-17 — CI green-up, e2e drift fixes, chunk-load auto-recover Cleans up the three pre-existing CI reds that had been failing on From 0c21d4c616d2a5b06b5f4d5dd2dd7aaddae44f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 18 May 2026 05:50:16 +0200 Subject: [PATCH 3/3] chore(release): bump package.json to 1.4.38.4 Mirrors the v1.4.38.4 tag (the previous release commit only carried the CHANGELOG entry because the editor lost the version-line edit on a merge race). `NEXT_PUBLIC_APP_VERSION` derives from this string at build time, so the running shell's self-heal poll would otherwise keep firing against itself. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fa91e4e5f..9af0a5db2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.38.3", + "version": "1.4.38.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",