mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(pwa): give the offline page a way back and reload once on any stale chunk
Without a connection a signed-in page opens the worker's offline page: signed-in pages carry `private, no-store` from the proxy and are never cached, so this is also what an offline launch of the installed app shows. The page now offers a real 64 px retry control, reloads by itself when the connection returns, follows the device's light or dark scheme and keeps clear of the notch and home indicator. The worker's comment on the page cache says plainly that only public pages land in it. The single reload after a stale-shell chunk failure moves into src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too, and it recognises a failed dynamic import as WebKit and Firefox word it. pwa-offline.spec.ts runs in the service-worker project and proves the offline strip, the offline page and the automatic recovery against the real worker.
This commit is contained in:
1 parent
503802e363
commit
ac212cf6b7
8 files changed
+271
-78
No files matched your search
@@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness {
|
||||
URL,
|
||||
Promise,
|
||||
console,
|
||||
// Read through the context so a test can install fake timers before
|
||||
// dispatching (the slow-navigation fallback arms one per navigation).
|
||||
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
|
||||
clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
@@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — offline page", () => {
|
||||
it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(res.status).toBe(503);
|
||||
const html = await res.text();
|
||||
// A real control, not a decorative icon: a link back to the same URL.
|
||||
expect(html).toMatch(/<a href="" aria-label="[^"]+"/);
|
||||
expect(html).toContain('addEventListener("online"');
|
||||
expect(html).toContain("prefers-color-scheme:light");
|
||||
expect(html).toContain("viewport-fit=cover");
|
||||
expect(html).toContain("env(safe-area-inset-bottom)");
|
||||
// Still language-neutral: no sentence in any one locale.
|
||||
expect(html).not.toMatch(/Keine Verbindung|You are offline/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — networkFirst privacy gate", () => {
|
||||
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
|
||||
* Unit tests for the chunk-error auto-reload guard in
|
||||
* `src/lib/pwa/chunk-reload.ts` (both error boundaries call it).
|
||||
*
|
||||
* The guard is keyed on the running shell's build version: one reload
|
||||
* attempt per BROKEN SHELL, not one per session. After a successful
|
||||
@@ -10,7 +11,11 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
|
||||
import {
|
||||
chunkReloadGuardValue,
|
||||
isChunkLoadError,
|
||||
shouldAttemptChunkReload,
|
||||
} from "@/lib/pwa/chunk-reload";
|
||||
|
||||
describe("shouldAttemptChunkReload", () => {
|
||||
it("reloads when no attempt is recorded", () => {
|
||||
@@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => {
|
||||
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isChunkLoadError", () => {
|
||||
it.each([
|
||||
[{ name: "ChunkLoadError", message: "x" }],
|
||||
[{ name: "Error", message: "Loading chunk 123 failed." }],
|
||||
[{ name: "Error", message: "Loading CSS chunk 7 failed." }],
|
||||
[
|
||||
{
|
||||
name: "Error",
|
||||
message: "Failed to load chunk /_next/static/chunks/a.js",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: "TypeError",
|
||||
message: "Failed to fetch dynamically imported module: /x.js",
|
||||
},
|
||||
],
|
||||
// WebKit and Firefox word a failed dynamic import differently.
|
||||
[{ name: "TypeError", message: "Importing a module script failed." }],
|
||||
[
|
||||
{
|
||||
name: "TypeError",
|
||||
message: "error loading dynamically imported module: /x.js",
|
||||
},
|
||||
],
|
||||
])("recognises %j", (err) => {
|
||||
expect(isChunkLoadError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves other errors to the error page", () => {
|
||||
expect(
|
||||
isChunkLoadError({ name: "TypeError", message: "x is undefined" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+5
-65
@@ -2,47 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDetails } from "@/components/error-details";
|
||||
|
||||
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
|
||||
|
||||
function isChunkLoadError(err: Error & { digest?: string }): boolean {
|
||||
if (err.name === "ChunkLoadError") return true;
|
||||
const msg = err.message ?? "";
|
||||
return (
|
||||
msg.includes("Loading chunk") ||
|
||||
msg.includes("Loading CSS chunk") ||
|
||||
msg.includes("Failed to load chunk") ||
|
||||
msg.includes("Failed to fetch dynamically imported module")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard value recorded per auto-reload attempt — the running shell's
|
||||
* build version, so the suppression is scoped to ONE shell. After a
|
||||
* successful heal the reloaded page carries the NEW version and the
|
||||
* guard re-arms by construction; only a shell that reloaded and is
|
||||
* STILL broken (the new build is also missing the chunk) stays
|
||||
* suppressed. Builds without an injected version degrade to the legacy
|
||||
* once-per-session behaviour via the "unversioned" sentinel.
|
||||
*/
|
||||
export function chunkReloadGuardValue(shellVersion: string): string {
|
||||
return shellVersion || "unversioned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `stored` is the sessionStorage value from the last attempt
|
||||
* (`null` when none happened); legacy guard values (the pre-v1.16.8
|
||||
* timestamp) never match a version string, so a session stuck from
|
||||
* before the change heals on its next chunk error.
|
||||
*/
|
||||
export function shouldAttemptChunkReload(
|
||||
stored: string | null,
|
||||
shellVersion: string,
|
||||
): boolean {
|
||||
return stored !== chunkReloadGuardValue(shellVersion);
|
||||
}
|
||||
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
@@ -60,30 +20,10 @@ export default function AppError({
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
|
||||
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error.
|
||||
// After a deploy the cached shell still references old chunk
|
||||
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
|
||||
// A single hard reload fetches the new shell + chunk graph.
|
||||
// The sessionStorage guard is keyed on the shell version (see
|
||||
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
|
||||
// broken shell but re-arms after every deploy — the pre-v1.16.8
|
||||
// once-per-session guard exhausted on multi-deploy days and left
|
||||
// the user on the error page with no automatic way out.
|
||||
if (typeof window !== "undefined" && isChunkLoadError(error)) {
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
|
||||
window.sessionStorage.setItem(
|
||||
CHUNK_RELOAD_KEY,
|
||||
chunkReloadGuardValue(SHELL_VERSION),
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage can throw under strict privacy modes — fall
|
||||
// through to the error UI in that case.
|
||||
}
|
||||
}
|
||||
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a
|
||||
// single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root
|
||||
// boundary).
|
||||
reloadOnceForChunkError(error);
|
||||
}, [error]);
|
||||
|
||||
return <ErrorDetails error={error} reset={reset} />;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
|
||||
|
||||
/**
|
||||
* Root-level error boundary. Renders when even the root layout itself fails,
|
||||
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
|
||||
@@ -12,6 +16,12 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
// A chunk of the root layout's client tree missing after a deploy lands
|
||||
// here rather than in `app/error.tsx`; it gets the same single reload.
|
||||
useEffect(() => {
|
||||
reloadOnceForChunkError(error);
|
||||
}, [error]);
|
||||
|
||||
async function handleCopy() {
|
||||
// Strip query string — OAuth callback routes carry sensitive tokens.
|
||||
const loc = typeof window !== "undefined" ? window.location : null;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* One reload after a stale-shell chunk failure.
|
||||
*
|
||||
* After a deploy, a page that was open across it still references the
|
||||
* previous build's chunk files; they 404 and the lazy load fails. A client
|
||||
* navigation already recovers on its own (Next turns a failed chunk during a
|
||||
* route change into a full page load), but a chunk that fails inside a page
|
||||
* (a `next/dynamic` panel, a lazily imported sheet) surfaces in an error
|
||||
* boundary instead. Both boundaries, the route one (`app/error.tsx`) and the
|
||||
* root one (`app/global-error.tsx`), call `reloadOnceForChunkError`, which
|
||||
* loads the page once so it arrives with the new build's chunk graph.
|
||||
*
|
||||
* The guard is keyed on the running shell's version: it suppresses a loop
|
||||
* within ONE broken shell, and re-arms after every deploy (the pre-v1.16.8
|
||||
* once-per-session key exhausted itself on multi-deploy days and stranded the
|
||||
* user on the error page).
|
||||
*/
|
||||
|
||||
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
|
||||
|
||||
/**
|
||||
* A failed chunk, in the words of each loader and engine: webpack's
|
||||
* `ChunkLoadError` / "Loading chunk", its CSS twin, Turbopack's "Failed to
|
||||
* load chunk", and a native dynamic import as Chromium, WebKit and Firefox
|
||||
* report it.
|
||||
*/
|
||||
export function isChunkLoadError(err: { name?: string; message?: string }) {
|
||||
if (err.name === "ChunkLoadError") return true;
|
||||
const msg = err.message ?? "";
|
||||
return (
|
||||
msg.includes("Loading chunk") ||
|
||||
msg.includes("Loading CSS chunk") ||
|
||||
msg.includes("Failed to load chunk") ||
|
||||
msg.includes("Failed to fetch dynamically imported module") ||
|
||||
msg.includes("Importing a module script failed") ||
|
||||
msg.includes("error loading dynamically imported module")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard value recorded per auto-reload attempt — the running shell's build
|
||||
* version, so the suppression is scoped to ONE shell. After a successful
|
||||
* heal the reloaded page carries the NEW version and the guard re-arms by
|
||||
* construction; only a shell that reloaded and is STILL broken (the new
|
||||
* build is also missing the chunk) stays suppressed. Builds without an
|
||||
* injected version degrade to once-per-session via the "unversioned"
|
||||
* sentinel.
|
||||
*/
|
||||
export function chunkReloadGuardValue(shellVersion: string): string {
|
||||
return shellVersion || "unversioned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reload decision. `stored` is the sessionStorage value from the last
|
||||
* attempt (`null` when none happened); legacy guard values (the pre-v1.16.8
|
||||
* timestamp) never match a version string, so a session stuck from before
|
||||
* the change heals on its next chunk error.
|
||||
*/
|
||||
export function shouldAttemptChunkReload(
|
||||
stored: string | null,
|
||||
shellVersion: string,
|
||||
): boolean {
|
||||
return stored !== chunkReloadGuardValue(shellVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload once if `error` is a chunk failure and this shell has not tried
|
||||
* yet. Returns whether a reload was started.
|
||||
*/
|
||||
export function reloadOnceForChunkError(
|
||||
error: { name?: string; message?: string },
|
||||
shellVersion: string = process.env.NEXT_PUBLIC_APP_VERSION || "",
|
||||
): boolean {
|
||||
if (typeof window === "undefined" || !isChunkLoadError(error)) return false;
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (!shouldAttemptChunkReload(stored, shellVersion)) return false;
|
||||
window.sessionStorage.setItem(
|
||||
CHUNK_RELOAD_KEY,
|
||||
chunkReloadGuardValue(shellVersion),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict privacy modes; without a guard
|
||||
// a reload could loop, so fall through to the error UI instead.
|
||||
return false;
|
||||
}
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user