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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user