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