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 }>(