fix: recover PWA after backgrounding
ci / web (push) Successful in 48s
ci / flake (push) Successful in 2m56s
ci / rust (push) Successful in 6m4s

This commit is contained in:
Miguel Palhas
2026-09-05 18:10:21 +01:00
parent 3529947497
commit 2e7c5f7f58
5 changed files with 81 additions and 0 deletions
+25
View File
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online"); expect(link.status().state).toBe("online");
}); });
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => { it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual(); const sched = manual();
const reload = vi.fn(); const reload = vi.fn();
+11
View File
@@ -148,6 +148,17 @@ export class DaemonLink {
void this.probe(); void this.probe();
} }
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/** /**
* One `/api/info` round-trip. Resolves `true` when the daemon answered. * One `/api/info` round-trip. Resolves `true` when the daemon answered.
* *
+15
View File
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
}); });
describe("CatalogStore reconnect", () => { describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => { it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets); const { store, scheduler } = storeWith(sockets);
+17
View File
@@ -59,6 +59,23 @@ export class CatalogStore {
this.connect(); this.connect();
} }
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */ /** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number { backoff(): number {
return this.backoffMs; return this.backoffMs;
+13
View File
@@ -107,6 +107,19 @@ void link.start();
store.start(); store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite. // without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) { if (import.meta.env.DEV && location.search.includes("mock")) {