From 4d53b3002b2454712b880c7ae933c23476a1c61e Mon Sep 17 00:00:00 2001 From: olegberman Date: Sun, 11 Oct 2026 14:00:48 -0400 Subject: [PATCH] Stop a blank page after a PWA update A missing hashed asset returned the app page with a one-year immutable cache header, and the service worker cached any successful response. During a deploy it could keep HTML in place of a script. A failed page chunk then removed the whole app until a hard reload. - Return an uncached 404 for missing files under /assets/, /fonts/, /icons/, and /sounds/. - Precache from the network, reject wrong responses before any file is stored, and keep only the final response after a redirect. Production redirects /offline.html to /offline, so the offline page needs this. - Catch page and message editor failures. Reload once after a load failure, then offer Reload. Co-Authored-By: Claude Opus 5.5 --- app/components/layout/load-boundary.tsx | 101 ++++++++ app/features/compose/composer-host.tsx | 187 +++++++------- app/workspace-pages.tsx | 271 ++++++++++---------- scripts/build-pwa.mjs | 26 +- test/unit/app/layout/load-boundary.test.tsx | 113 ++++++++ test/unit/scripts/pwa-precache.test.mjs | 77 ++++++ test/unit/worker/index-static-files.test.ts | 41 +++ worker/index.ts | 7 + 8 files changed, 598 insertions(+), 225 deletions(-) create mode 100644 app/components/layout/load-boundary.tsx create mode 100644 test/unit/app/layout/load-boundary.test.tsx create mode 100644 test/unit/scripts/pwa-precache.test.mjs create mode 100644 test/unit/worker/index-static-files.test.ts diff --git a/app/components/layout/load-boundary.tsx b/app/components/layout/load-boundary.tsx new file mode 100644 index 00000000..8d22d4fc --- /dev/null +++ b/app/components/layout/load-boundary.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; + +const reloadStorageKey = "hqbase:load-failure-reload"; +const reloadWindowMs = 60_000; + +export class LoadFailure extends Error { + constructor(cause: unknown) { + super("A part of HQBase could not load.", { cause }); + this.name = "LoadFailure"; + } +} + +export const lazyComponent: typeof React.lazy = (load) => + React.lazy(() => + load().catch((error: unknown) => { + throw new LoadFailure(error); + }) + ); + +type LoadBoundaryProps = { + children: React.ReactNode; + message: string; + resetKey?: string; + variant: "notice" | "page"; +}; + +type LoadBoundaryState = { + failure: "reload" | "show" | null; +}; + +export class LoadBoundary extends React.Component { + override state: LoadBoundaryState = { failure: null }; + + static getDerivedStateFromError(error: unknown): LoadBoundaryState { + return { failure: error instanceof LoadFailure && mayReload(Date.now()) ? "reload" : "show" }; + } + + override componentDidCatch(): void { + if (this.state.failure !== "reload") return; + // A new release can remove the files of the running page. One reload gets the current files. + if (recordReload(Date.now())) window.location.reload(); + else this.setState({ failure: "show" }); + } + + override componentDidUpdate(previous: LoadBoundaryProps): void { + if (this.state.failure !== null && previous.resetKey !== this.props.resetKey) { + this.setState({ failure: null }); + } + } + + override render(): React.ReactNode { + if (this.state.failure === null) return this.props.children; + if (this.state.failure === "reload") return null; + const reload = ( + + ); + return this.props.variant === "page" ? ( +
+

{this.props.message}

+ {reload} +
+ ) : ( + + ); + } +} + +function mayReload(now: number): boolean { + try { + const previous = Number(window.sessionStorage.getItem(reloadStorageKey)); + return !(previous > 0 && now - previous < reloadWindowMs); + } catch { + return false; + } +} + +function recordReload(now: number): boolean { + try { + window.sessionStorage.setItem(reloadStorageKey, String(now)); + return true; + } catch { + return false; + } +} diff --git a/app/features/compose/composer-host.tsx b/app/features/compose/composer-host.tsx index 6e70bad1..ab442096 100644 --- a/app/features/compose/composer-host.tsx +++ b/app/features/compose/composer-host.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { LoadBoundary, lazyComponent } from "@/components/layout/load-boundary"; import type { Mailbox } from "@/features/mailboxes/types"; import { ConversationMessages } from "@/features/messages/conversation-messages"; @@ -22,7 +23,7 @@ export type { } from "./composer-context"; export { ComposerInlineTarget, useComposer } from "./composer-context"; -const ComposeDialog = React.lazy(() => +const ComposeDialog = lazyComponent(() => import("./compose-dialog").then((module) => ({ default: module.ComposeDialog })) ); @@ -272,95 +273,101 @@ export function ComposerHost({ const inlineTarget = session.detached ? null : (inlineTargets[session.id] ?? parkingTarget); const presentation = session.detached ? "window" : "thread"; return ( - - item.id === session.id) - )} - dockTarget={dockTarget} - draftId={session.initialDraftId} - initialTo={session.initialTo} - inlineTarget={inlineTarget} - mailboxes={mailboxes} - message={session.message} - minimized={session.minimized} - mode={session.mode} - open - presentation={presentation} - threadContext={ - session.messages.length > 0 ? ( - - ) : undefined - } - threadMessages={session.messages} - windowSlot={Math.max( - 0, - windowSessions.length - - 1 - - windowSessions.findIndex((item) => item.id === session.id) - )} - onDetach={ - presentation === "thread" - ? () => { - updateSessions((current) => - current.map((candidate) => - candidate.id === session.id - ? { ...candidate, detached: true, minimized: false } - : candidate - ) - ); - } - : undefined - } - onDraftReady={(draftId) => { - updateSessions((current) => - current.map((candidate) => - candidate.id === session.id ? { ...candidate, draftId } : candidate - ) - ); - }} - onDraftsChange={() => onDraftsChangeRef.current()} - onManageSignatures={() => onManageSignaturesRef.current()} - onMinimizedChange={(minimized) => { - updateSessions((current) => - current.map((candidate) => - candidate.id === session.id ? { ...candidate, minimized } : candidate - ) - ); - }} - onOpenChange={(open) => { - if (!open) closeSession(session.id); - }} - onReturnToThread={ - origin - ? () => { - updateSessions((current) => - current.map((candidate) => - candidate.id === session.id - ? { - ...candidate, - detached: false, - minimized: false - } - : candidate - ) - ); - navigateRef.current({ - kind: "mail", - folder: origin.folder, - messageId: origin.messageId - }); - } - : undefined - } - onSent={() => { - onSentRef.current(); - session.onSent?.(); - }} - /> - + + + item.id === session.id) + )} + dockTarget={dockTarget} + draftId={session.initialDraftId} + initialTo={session.initialTo} + inlineTarget={inlineTarget} + mailboxes={mailboxes} + message={session.message} + minimized={session.minimized} + mode={session.mode} + open + presentation={presentation} + threadContext={ + session.messages.length > 0 ? ( + + ) : undefined + } + threadMessages={session.messages} + windowSlot={Math.max( + 0, + windowSessions.length - + 1 - + windowSessions.findIndex((item) => item.id === session.id) + )} + onDetach={ + presentation === "thread" + ? () => { + updateSessions((current) => + current.map((candidate) => + candidate.id === session.id + ? { ...candidate, detached: true, minimized: false } + : candidate + ) + ); + } + : undefined + } + onDraftReady={(draftId) => { + updateSessions((current) => + current.map((candidate) => + candidate.id === session.id ? { ...candidate, draftId } : candidate + ) + ); + }} + onDraftsChange={() => onDraftsChangeRef.current()} + onManageSignatures={() => onManageSignaturesRef.current()} + onMinimizedChange={(minimized) => { + updateSessions((current) => + current.map((candidate) => + candidate.id === session.id ? { ...candidate, minimized } : candidate + ) + ); + }} + onOpenChange={(open) => { + if (!open) closeSession(session.id); + }} + onReturnToThread={ + origin + ? () => { + updateSessions((current) => + current.map((candidate) => + candidate.id === session.id + ? { + ...candidate, + detached: false, + minimized: false + } + : candidate + ) + ); + navigateRef.current({ + kind: "mail", + folder: origin.folder, + messageId: origin.messageId + }); + } + : undefined + } + onSent={() => { + onSentRef.current(); + session.onSent?.(); + }} + /> + + ); })} diff --git a/app/workspace-pages.tsx b/app/workspace-pages.tsx index 41606e4c..c8675cec 100644 --- a/app/workspace-pages.tsx +++ b/app/workspace-pages.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { LoadBoundary, lazyComponent } from "@/components/layout/load-boundary"; import { Spinner } from "@/components/ui/spinner"; import type { CurrentUser } from "@/features/auth/types"; @@ -17,19 +18,19 @@ import type { WorkspaceUser } from "@/features/users/types"; import type { AppRoute, FolderId, MailFolderId, SettingsTabId } from "@/lib/routes"; import type { useAppRoute } from "@/lib/use-app-route"; -const AgentsPage = React.lazy(() => +const AgentsPage = lazyComponent(() => import("@/features/agents/agents-page").then((module) => ({ default: module.AgentsPage })) ); -const ContactsPage = React.lazy(() => +const ContactsPage = lazyComponent(() => import("@/features/contacts/contacts-page").then((module) => ({ default: module.ContactsPage })) ); -const SettingsPage = React.lazy(() => +const SettingsPage = lazyComponent(() => import("@/features/settings/settings-page").then((module) => ({ default: module.SettingsPage })) ); -const DraftComposeDialog = React.lazy(() => +const DraftComposeDialog = lazyComponent(() => import("@/features/drafts/draft-compose-dialog").then((module) => ({ default: module.DraftComposeDialog })) @@ -106,140 +107,142 @@ export function WorkspacePages({ ); return ( - - - - } - > - {activeFolder === "agents" ? ( - - ) : activeFolder === "contacts" ? ( - navigate({ kind: "contacts", contactId: null })} - onCompose={onCompose} - onOpenConversation={(conversation) => - navigate({ - kind: "mail", - folder: conversation.folder, - messageId: conversation.id - }) - } - onLabelsChanged={onLabelsChanged} - onSelect={(contactId) => navigate({ kind: "contacts", contactId })} - /> - ) : activeFolder === "settings" ? ( - - ) : activeFolder === "drafts" && selectedDraft && selectedDraftHasContext ? ( - - Opening draft… - - } - > - { - if (!open) navigate({ kind: "drafts", draftId: null }); + + + + + } + > + {activeFolder === "agents" ? ( + + ) : activeFolder === "contacts" ? ( + navigate({ kind: "contacts", contactId: null })} + onCompose={onCompose} + onOpenConversation={(conversation) => + navigate({ + kind: "mail", + folder: conversation.folder, + messageId: conversation.id + }) + } + onLabelsChanged={onLabelsChanged} + onSelect={(contactId) => navigate({ kind: "contacts", contactId })} + /> + ) : activeFolder === "settings" ? ( + + ) : activeFolder === "drafts" && selectedDraft && selectedDraftHasContext ? ( + + Opening draft… + + } + > + { + if (!open) navigate({ kind: "drafts", draftId: null }); + }} + onSent={sent} + /> + + ) : activeFolder === "drafts" ? ( + navigate({ kind: "drafts", draftId: null })} + onLabelChange={onLabelChange} + onLabelsChanged={onLabelsChanged} + onSelect={(draftId) => navigate({ kind: "drafts", draftId })} + onToggleLabel={async (draftId, label, assigned) => { + const result = await setDraftLabel(draftId, label.id, assigned); + draftState.applyLabels(result.draftId, result.labels); }} - onSent={sent} /> - - ) : activeFolder === "drafts" ? ( - navigate({ kind: "drafts", draftId: null })} - onLabelChange={onLabelChange} - onLabelsChanged={onLabelsChanged} - onSelect={(draftId) => navigate({ kind: "drafts", draftId })} - onToggleLabel={async (draftId, label, assigned) => { - const result = await setDraftLabel(draftId, label.id, assigned); - draftState.applyLabels(result.draftId, result.labels); - }} - /> - ) : ( - void mailSync.loadMore()} - onMessageRouteChange={(folder, messageId) => - navigate({ kind: "mail", folder, messageId }) - } - onRefresh={() => mailSync.refresh()} - onSelect={(messageId) => - navigate({ kind: "mail", folder: activeFolder as MailFolderId, messageId }) - } - /> - )} - - {selectedDraft && !selectedDraftHasContext ? ( - - { - if (!open) navigate({ kind: "drafts", draftId: null }); - }} - onSent={sent} + onLabelChange={onLabelChange} + onLabelsChanged={onLabelsChanged} + onLoadMore={() => void mailSync.loadMore()} + onMessageRouteChange={(folder, messageId) => + navigate({ kind: "mail", folder, messageId }) + } + onRefresh={() => mailSync.refresh()} + onSelect={(messageId) => + navigate({ kind: "mail", folder: activeFolder as MailFolderId, messageId }) + } /> - - ) : null} - + )} + + {selectedDraft && !selectedDraftHasContext ? ( + + { + if (!open) navigate({ kind: "drafts", draftId: null }); + }} + onSent={sent} + /> + + ) : null} + + ); } diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs index d670efd8..40c66dcd 100644 --- a/scripts/build-pwa.mjs +++ b/scripts/build-pwa.mjs @@ -47,9 +47,33 @@ const CACHE_NAME = ${JSON.stringify(cacheName)}; const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)}; self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))); + event.waitUntil(precache()); }); +async function precache() { + const entries = await Promise.all( + PRECACHE_URLS.map(async (url) => [url, await precacheResponse(url)]) + ); + const cache = await caches.open(CACHE_NAME); + await Promise.all(entries.map(([url, response]) => cache.put(url, response))); +} + +async function precacheResponse(url) { + // The browser cache can hold the app page from an earlier request for a missing file. + const response = await fetch(url, { cache: "reload" }); + const type = (response.headers.get("content-type") || "").split(";")[0].trim().toLowerCase(); + if (!response.ok || (type === "text/html") !== (url === "/offline.html")) { + throw new Error("A precache file has an unexpected response."); + } + if (!response.redirected) return response; + // A navigation cannot use a stored redirect, so keep only the final response. + return new Response(await response.blob(), { + headers: response.headers, + status: response.status, + statusText: response.statusText + }); +} + self.addEventListener("activate", (event) => { event.waitUntil( Promise.all([ diff --git a/test/unit/app/layout/load-boundary.test.tsx b/test/unit/app/layout/load-boundary.test.tsx new file mode 100644 index 00000000..aa3fe536 --- /dev/null +++ b/test/unit/app/layout/load-boundary.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LoadBoundary, lazyComponent } from "@/components/layout/load-boundary"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +const reloadKey = "hqbase:load-failure-reload"; + +function brokenPage() { + return lazyComponent(() => + Promise.reject(new TypeError("Failed to fetch dynamically imported module")) + ); +} + +function ThrowsWhileShowing(): React.ReactElement { + throw new Error("Render failed"); +} + +async function show(content: React.ReactNode, resetKey = "settings") { + const view = await renderComponent( + + Loading

}>{content}
+
+ ); + for (let flush = 0; flush < 4; flush += 1) await flushHookEffects(); + return view; +} + +describe("load boundary", () => { + let reload: ReturnType; + + beforeEach(() => { + window.sessionStorage.clear(); + reload = vi.spyOn(window.location, "reload").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reloads once when a page cannot load", async () => { + const Page = brokenPage(); + const view = await show(); + + expect(reload).toHaveBeenCalledOnce(); + expect(Number(window.sessionStorage.getItem(reloadKey))).toBeGreaterThan(0); + expect(view.container.textContent).toBe(""); + await view.unmount(); + }); + + it("offers Reload when the page fails again within one minute", async () => { + window.sessionStorage.setItem(reloadKey, String(Date.now() - 30_000)); + const Page = brokenPage(); + const view = await show(); + + expect(reload).not.toHaveBeenCalled(); + expect(view.container.querySelector('[role="alert"]')?.textContent).toContain( + "HQBase could not open this page." + ); + await flushHookEffects(() => view.container.querySelector("button")?.click()); + expect(reload).toHaveBeenCalledOnce(); + await view.unmount(); + }); + + it("reloads again after the one-minute window", async () => { + window.sessionStorage.setItem(reloadKey, String(Date.now() - 61_000)); + const Page = brokenPage(); + const view = await show(); + + expect(reload).toHaveBeenCalledOnce(); + await view.unmount(); + }); + + it("offers Reload without reloading when a page fails while it shows", async () => { + const view = await show(); + + expect(reload).not.toHaveBeenCalled(); + expect(view.container.textContent).toContain("HQBase could not open this page."); + expect(view.container.textContent).toContain("Reload"); + await view.unmount(); + }); + + it("shows the next page after the route changes", async () => { + const view = await show(); + await view.rerender( + +

Inbox

+
+ ); + + expect(view.container.textContent).toBe("Inbox"); + await view.unmount(); + }); + + it("keeps the rest of the app visible with a compact notice", async () => { + const view = await renderComponent( +
+

Inbox

+ + + +
+ ); + + expect(view.container.textContent).toContain("Inbox"); + const notice = view.container.querySelector("aside[role='alert']"); + expect(notice?.textContent).toContain("HQBase could not open the message editor."); + expect(notice?.className).toContain("fixed"); + await view.unmount(); + }); +}); diff --git a/test/unit/scripts/pwa-precache.test.mjs b/test/unit/scripts/pwa-precache.test.mjs new file mode 100644 index 00000000..6f214b24 --- /dev/null +++ b/test/unit/scripts/pwa-precache.test.mjs @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +import { renderServiceWorker } from "../../../scripts/build-pwa.mjs"; + +const precacheUrls = ["/assets/app-abc.js", "/fonts/Inter.woff2", "/offline.html"]; +const types = { + "/assets/app-abc.js": "text/javascript", + "/fonts/Inter.woff2": "font/woff2", + "/offline.html": "text/html; charset=utf-8" +}; + +function response(body, contentType, { redirected = false, status = 200 } = {}) { + const result = new Response(body, { headers: { "content-type": contentType }, status }); + if (redirected) Object.defineProperty(result, "redirected", { value: true }); + return result; +} + +function installWorker(serve) { + const handlers = {}; + const stored = new Map(); + const caches = { + open: vi.fn(async () => ({ + put: async (url, value) => { + stored.set(url, value); + } + })) + }; + const fetch = vi.fn(async (url) => serve(url)); + const self = { addEventListener: (type, handler) => (handlers[type] = handler) }; + const source = renderServiceWorker({ cacheName: "hqbase-pwa-test-2", precacheUrls }); + new Function("self", "caches", "fetch", source)(self, caches, fetch); + + let installation; + handlers.install({ waitUntil: (promise) => (installation = promise) }); + return { caches, fetch, installation, stored }; +} + +describe("service worker precache", () => { + it("reads every file from the network and stores it only after all files pass", async () => { + const worker = installWorker((url) => response(`body of ${url}`, types[url])); + + await worker.installation; + + for (const url of precacheUrls) { + expect(worker.fetch).toHaveBeenCalledWith(url, { cache: "reload" }); + expect(await worker.stored.get(url).text()).toBe(`body of ${url}`); + } + }); + + it.each([ + ["the app page for a script", "/assets/app-abc.js", () => response("", "text/html")], + ["a failed response", "/fonts/Inter.woff2", () => response("", "text/plain", { status: 404 })], + ["an offline page that is not HTML", "/offline.html", () => response("{}", "text/plain")] + ])("does not install after %s", async (_name, failingUrl, failure) => { + const worker = installWorker((url) => + url === failingUrl ? failure() : response("ok", types[url]) + ); + + await expect(worker.installation).rejects.toThrow("unexpected response"); + expect(worker.caches.open).not.toHaveBeenCalled(); + expect(worker.stored.size).toBe(0); + }); + + it("stores a redirected offline page as its final response", async () => { + const worker = installWorker((url) => + response(`body of ${url}`, types[url], { redirected: url === "/offline.html" }) + ); + + await worker.installation; + + const offline = worker.stored.get("/offline.html"); + expect(offline.redirected).toBe(false); + expect(offline.status).toBe(200); + expect(offline.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(await offline.text()).toBe("body of /offline.html"); + }); +}); diff --git a/test/unit/worker/index-static-files.test.ts b/test/unit/worker/index-static-files.test.ts new file mode 100644 index 00000000..850d5e1c --- /dev/null +++ b/test/unit/worker/index-static-files.test.ts @@ -0,0 +1,41 @@ +import worker from "@worker/index"; +import type { WorkerEnv } from "@worker/lib/env"; +import { describe, expect, it, vi } from "vitest"; + +function workerWithAppPage() { + const assets = vi.fn( + async () => new Response("", { headers: { "content-type": "text/html" } }) + ); + const env = { ASSETS: { fetch: assets } } as unknown as WorkerEnv; + const ctx = { waitUntil: vi.fn() } as unknown as ExecutionContext; + return { + assets, + fetch: (path: string) => worker.fetch(new Request(`https://mail.example.test${path}`), env, ctx) + }; +} + +describe("missing static files", () => { + it.each([ + "/assets/settings-page-missing.js", + "/assets/app-missing.css", + "/fonts/Inter-Missing.woff2", + "/icons/missing.png", + "/sounds/missing.wav" + ])("returns an uncached 404 instead of the app page for %s", async (path) => { + const app = workerWithAppPage(); + const response = await app.fetch(path); + + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).not.toContain("text/html"); + expect(app.assets).not.toHaveBeenCalled(); + }); + + it("keeps the app page for client routes", async () => { + const app = workerWithAppPage(); + const response = await app.fetch("/settings/updates"); + + expect(response.status).toBe(200); + expect(app.assets).toHaveBeenCalledOnce(); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 952ae8bd..b7637b0e 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -15,6 +15,8 @@ import { apiRoutes } from "./routes"; export { MailEvents }; +const STATIC_FILE_DIRECTORIES = ["/assets/", "/fonts/", "/icons/", "/sounds/"]; + export default { async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise { const url = new URL(request.url); @@ -29,6 +31,11 @@ export default { if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/management/")) { return apiRoutes.fetch(request, env, ctx); } + // Existing static files never reach the Worker. A missing one must not get the app page, + // because the browser or the service worker can keep that HTML in place of a script. + if (STATIC_FILE_DIRECTORIES.some((directory) => url.pathname.startsWith(directory))) { + return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } }); + } const portal = request.headers.get("accept")?.includes("text/html") ? await getRow<{ is_canonical: number; canonical_hostname: string }>(