Merge pull request #119 from LINUXexpert-org/reload-on-new-build

Reload when the server is running a newer build
This commit is contained in:
LINUXexpert.org
2026-08-27 22:24:18 -07:00
committed by GitHub
3 changed files with 153 additions and 1 deletions
+67
View File
@@ -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<typeof vi.fn>;
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();
});
});
+79
View File
@@ -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<boolean> {
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;
}
+7 -1
View File
@@ -5,6 +5,7 @@ import { push, type PushState } from "@/jmap/push";
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting"; import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
import { setServerLocale } from "@/lib/datetime"; import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
import { unsubscribeThisDevice } from "@/lib/webpush"; import { unsubscribeThisDevice } from "@/lib/webpush";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -119,7 +120,12 @@ client.onUnauthenticated(() => {
push.stop(); push.stop();
stopSettingsSync(); stopSettingsSync();
client.session = null; 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 })); push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));