diff --git a/web/src/lib/__tests__/staleBuild.test.ts b/web/src/lib/__tests__/staleBuild.test.ts new file mode 100644 index 0000000..a265d36 --- /dev/null +++ b/web/src/lib/__tests__/staleBuild.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { reloadIfServerRebuilt } from "@/lib/staleBuild"; +import { APP_VERSION } from "@/lib/version"; + +function healthReplies(body: unknown, ok = true) { + return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response); +} + +let reload: ReturnType; + +beforeEach(() => { + sessionStorage.clear(); + reload = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, reload }, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("reloadIfServerRebuilt", () => { + it("reloads when the server reports a different build", async () => { + vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` })); + expect(await reloadIfServerRebuilt()).toBe(true); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("leaves the page alone when the versions match", async () => { + vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION })); + expect(await reloadIfServerRebuilt()).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads once per version, not once per 401", async () => { + vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" })); + expect(await reloadIfServerRebuilt()).toBe(true); + expect(await reloadIfServerRebuilt()).toBe(false); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("clears the guard once the versions agree again", async () => { + vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" })); + await reloadIfServerRebuilt(); + vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION })); + await reloadIfServerRebuilt(); + vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" })); + expect(await reloadIfServerRebuilt()).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("does not reload when the server cannot be reached", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + expect(await reloadIfServerRebuilt()).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("does not reload on a bad response or a missing version", async () => { + vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false)); + expect(await reloadIfServerRebuilt()).toBe(false); + vi.stubGlobal("fetch", healthReplies({ ok: true })); + expect(await reloadIfServerRebuilt()).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/staleBuild.ts b/web/src/lib/staleBuild.ts new file mode 100644 index 0000000..0c208b3 --- /dev/null +++ b/web/src/lib/staleBuild.ts @@ -0,0 +1,79 @@ +import { APP_VERSION } from "./version"; + +/** + * Reload the page when the server is serving a build this one did not come + * from. + * + * Signing out and picking up a new version are separate things, and only the + * first happens on its own. An immutable instance holds sessions in memory, so + * a deploy signs everyone out -- but the tab that was open still has the old + * bundle in it, and a 401 only swaps the view to the sign-in form. The old + * JavaScript would go on talking to the new server until someone happened to + * reload by hand. + * + * `index.html` is served `no-cache` and the assets under it are content-hashed + * and immutable, so a reload is all it takes; the only missing part was + * something to ask for one. Checking on a 401 rather than on a timer keeps it + * to the moment it matters and costs one small request, and comparing versions + * rather than reloading on every 401 means an ordinary session expiry still + * lands on the sign-in form with the page intact. + */ +const TRIED_KEY = "ihasmail:reloaded-for"; + +/** sessionStorage throws outright in some privacy modes; treat that as absent. */ +function tried(): string | null { + try { + return sessionStorage.getItem(TRIED_KEY); + } catch { + return null; + } +} + +function remember(version: string): void { + try { + sessionStorage.setItem(TRIED_KEY, version); + } catch { + /* nothing to do: the guard below is best-effort */ + } +} + +function forget(): void { + try { + sessionStorage.removeItem(TRIED_KEY); + } catch { + /* as above */ + } +} + +/** + * True when a reload has been asked for and the caller should leave the page + * alone. False for every other outcome, including not being able to tell -- + * failing to reach the server is not a reason to throw away what is on screen. + */ +export async function reloadIfServerRebuilt(): Promise { + let serverVersion: string; + try { + const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" }); + if (!res.ok) return false; + const body = (await res.json()) as { version?: unknown }; + if (typeof body.version !== "string" || !body.version) return false; + serverVersion = body.version; + } catch { + return false; + } + + if (serverVersion === APP_VERSION) { + // Back in step, either because nothing changed or because an earlier + // reload worked. Clear the guard so the next deploy is not mistaken for + // one already attempted. + forget(); + return false; + } + // Reloading once per version, not once per 401: if the new bundle somehow + // still reports the old version -- a stale proxy cache, a half-finished + // deploy -- this stops the two of them reloading each other in a loop. + if (tried() === serverVersion) return false; + remember(serverVersion); + window.location.reload(); + return true; +} diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 2e7a562..db82aa3 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -5,6 +5,7 @@ import { push, type PushState } from "@/jmap/push"; import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting"; import { setServerLocale } from "@/lib/datetime"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; +import { reloadIfServerRebuilt } from "@/lib/staleBuild"; import { unsubscribeThisDevice } from "@/lib/webpush"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -119,7 +120,12 @@ client.onUnauthenticated(() => { push.stop(); stopSettingsSync(); client.session = null; - useSession.setState({ status: "anonymous", session: null, accountId: null }); + // Ask before showing the sign-in form rather than after. A deploy is the + // usual reason to be signed out here, and reloading a form someone has + // already started typing into would throw the password away. + void reloadIfServerRebuilt().then((reloading) => { + if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null }); + }); }); push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));