);
+ vi.spyOn(window, "matchMedia").mockImplementation(() => ({ matches: false }) as MediaQueryList);
+ watchForStalePage();
+ });
+
+ afterEach(() => {
+ SdkConfig.reset();
+ vi.restoreAllMocks();
+ });
+
+ it("tells the user to reload when a chunk fails to load, once", () => {
+ reject(Object.assign(new Error("Loading chunk 6685 failed"), { name: "ChunkLoadError" }));
+ expect(Modal.createDialog).toHaveBeenCalledTimes(1);
+ const [component, props] = vi.mocked(Modal.createDialog).mock.calls[0];
+ expect(component).toBe(ErrorDialog);
+ expect(props).toMatchObject({ title: "This page is out of date", button: "Reload" });
+ const { container } = render(<>{(props as { description: React.ReactNode }).description}>);
+ expect(container.textContent).toContain("Support has been updated since this page was opened");
+ expect(container.querySelectorAll("kbd").length).toBeGreaterThan(0);
+
+ reject(Object.assign(new Error("Loading chunk 1 failed"), { name: "ChunkLoadError" }));
+ expect(Modal.createDialog).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores other rejections", () => {
+ reject(new Error("something else"));
+ reject("not an error");
+ expect(Modal.createDialog).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/vector/stalePage.tsx b/apps/web/src/vector/stalePage.tsx
new file mode 100644
index 00000000000..44e73ababb6
--- /dev/null
+++ b/apps/web/src/vector/stalePage.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2026 Start9 Labs, Inc.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React, { type JSX } from "react";
+
+import Modal from "../Modal";
+import ErrorDialog from "../components/views/dialogs/ErrorDialog";
+import { KeyboardShortcut } from "../components/views/settings/KeyboardShortcut";
+import { PHONE_LAYOUT_QUERY } from "../hooks/usePhoneLayout";
+import { _t } from "../languageHandler";
+import SdkConfig from "../SdkConfig";
+
+let shown = false;
+
+// A deploy replaces the build's files, so a page opened before it fails the next time it loads a chunk on demand.
+export function watchForStalePage(): void {
+ window.addEventListener("unhandledrejection", (event) => {
+ if (!(event.reason instanceof Error) || event.reason.name !== "ChunkLoadError" || shown) return;
+ shown = true;
+ event.preventDefault();
+ Modal.createDialog(ErrorDialog, {
+ title: _t("start9|stale_page|title"),
+ description: ,
+ button: _t("action|reload"),
+ onFinished: () => window.location.reload(),
+ });
+ });
+}
+
+function StalePageDescription(): JSX.Element {
+ const phone = window.matchMedia(PHONE_LAYOUT_QUERY).matches;
+ return (
+ <>
+ {_t("start9|stale_page|description", { brand: SdkConfig.get().brand })}
+ {phone ? _t("start9|stale_page|phone") : _t("start9|stale_page|keys")}
+ {!phone && }
+ >
+ );
+}
diff --git a/docs/fork.md b/docs/fork.md
index a7d2d1ad2dd..eb950587db7 100644
--- a/docs/fork.md
+++ b/docs/fork.md
@@ -15,8 +15,16 @@ unaffected. No configuration is needed beyond the well-known.
Below 768px the client shows one pane at a time: the room list, the room, or a right-panel card such as a thread or
room info, with a back button in the room header. Dialogs and the sign-in, register and forgot-password pages fit
-the screen. Phone browsers are no longer redirected to a native-app page, the client offers no app-store links, and
-a current phone browser (Chrome, Firefox, Safari, Edge, Samsung Internet) is not warned as unsupported.
+the screen. The room header keeps the room name, threads and room info and drops the call buttons and member
+avatars; the search box shows no keyboard shortcut. The composer stays above the on-screen keyboard, and an
+installed app keeps clear of the notch and home indicator. Phone browsers are no longer redirected to a native-app
+page, the client offers no app-store links, and a current phone browser (Chrome, Firefox, Safari, Edge, Samsung
+Internet) is not warned as unsupported.
+
+## Stale pages
+
+A page left open across a deploy tells the user it is out of date and how to reload, instead of failing silently the
+next time it needs part of the app it can no longer fetch.
## Installable app