fix: recover PWA after backgrounding
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user