Merge remote-tracking branch 'origin/main' into develop

This commit is contained in:
Marc-André Bombeck committed 2026-05-18 05:51:45 +02:00
commit 96e7442bab
6 files changed
+190 -2

No files matched your search

+42
View File
@@ -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
- **`<VersionPoller>` client component** mounted inside
`<Providers>`. 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
+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
+1 -1
View File
@@ -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",
+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;
}