Merge pull request 'fix(canvas/socket): wake WebSocket on visibilitychange / pageshow (#223 / #228)' (#1530) from fix/canvas-ws-visibility-reconnect into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 3m28s
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 3m28s
This commit was merged in pull request #1530.
This commit is contained in:
@@ -63,7 +63,7 @@ class MockWebSocket {
|
||||
(globalThis as unknown as Record<string, unknown>).WebSocket = MockWebSocket;
|
||||
|
||||
// Now import the socket module (uses globalThis.WebSocket at call time)
|
||||
import { connectSocket, disconnectSocket } from "../socket";
|
||||
import { connectSocket, disconnectSocket, wakeSocket } from "../socket";
|
||||
import { useCanvasStore } from "../canvas";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -416,3 +416,84 @@ describe("RehydrateDedup", () => {
|
||||
expect(d.shouldSkip(2_700)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeSocket() — visibility-wake reconnect (regression #223 / #228)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mobile browsers (iOS Safari, Chrome on Android in deep-sleep) silently
|
||||
// drop the WebSocket when the tab is backgrounded; the in-page onclose
|
||||
// fires very late or never. Without a visibility wake, the canvas stays
|
||||
// frozen until the user manually refreshes.
|
||||
//
|
||||
// The real wiring lives at module level: connectSocket installs a
|
||||
// visibilitychange/pageshow listener that calls wake() on foreground.
|
||||
// We can't dispatch DOM events here because the suite runs under the
|
||||
// `node` test environment (no `document`/`window` — see canvas/vitest
|
||||
// .config.ts). Instead we test wake() directly through the wakeSocket
|
||||
// public export, which is the same code path the listener invokes.
|
||||
|
||||
describe("wakeSocket → reconnect (#223 / #228 — mobile visibility wake)", () => {
|
||||
it("wake on a healthy OPEN socket does not create a new WebSocket", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// OPEN === 1. wake() should take the healthy-no-op branch.
|
||||
(ws as unknown as { readyState: number }).readyState = 1;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake on a CLOSED socket creates a new WebSocket (the actual #223 fix)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// CLOSED === 3. Simulates the OS killing the socket while the tab
|
||||
// was backgrounded. We deliberately don't fire triggerClose() —
|
||||
// the whole point of #223 is that mobile browsers don't fire
|
||||
// onclose when they kill the WS, so reconnect never schedules.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("wake while CONNECTING (readyState=0) does not pile another handshake", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
// CONNECTING === 0 — a handshake is already in flight.
|
||||
(ws as unknown as { readyState: number }).readyState = 0;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake cancels any pending backoff reconnect", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// Drop the socket — onclose schedules a backoff reconnect.
|
||||
ws.triggerClose();
|
||||
// Now wake the page. wake() should pre-empt the backoff so the
|
||||
// user sees the canvas come back immediately, not after the
|
||||
// exponential delay window.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
clearTimeoutSpy.mockClear();
|
||||
wakeSocket();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("wake after disconnectSocket is a no-op (no zombie reconnect)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
disconnectSocket();
|
||||
const before = MockWebSocket.instances.length;
|
||||
// Singleton is null now — wake() should silently do nothing.
|
||||
expect(() => wakeSocket()).not.toThrow();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +268,46 @@ class ReconnectingSocket {
|
||||
}
|
||||
useCanvasStore.getState().setWsStatus("disconnected");
|
||||
}
|
||||
|
||||
/** Force a reconnect attempt now, skipping the backoff window.
|
||||
* Used by the visibilitychange / pageshow handler: when a mobile
|
||||
* browser backgrounds the tab, the OS silently kills the WebSocket
|
||||
* but the in-page onclose either fires very late or never fires at
|
||||
* all (iOS Safari, Chrome on Android in deep-sleep). Once the user
|
||||
* brings the tab back, the canvas needs to reconnect within human
|
||||
* perception — not on whatever backoff delay was last scheduled,
|
||||
* which can be up to 30s. (#223 / #228)
|
||||
*
|
||||
* Idempotent: if the socket is already OPEN we leave it alone; the
|
||||
* WebSocket is still healthy and a reconnect would just churn. */
|
||||
wake() {
|
||||
if (this.disposed) return;
|
||||
// OPEN === 1. Use the numeric literal so we don't have to import
|
||||
// WebSocket type values; the runtime constant is well-defined.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
// Healthy. Run a rehydrate to catch any events we may have missed
|
||||
// while the tab was backgrounded — the OS does deliver some
|
||||
// packets late, but it can also drop them, and the dedup gate
|
||||
// collapses this with any subsequent health-check rehydrate.
|
||||
void this.rehydrate();
|
||||
return;
|
||||
}
|
||||
// CONNECTING === 0 means a handshake is already in flight. Don't
|
||||
// pile another one on; the existing attempt or its onclose-driven
|
||||
// reconnect will resolve.
|
||||
if (this.ws && this.ws.readyState === 0) return;
|
||||
// Otherwise (CLOSING, CLOSED, or null) we're in limbo. Cancel any
|
||||
// pending backoff and reconnect now.
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
// Reset attempt counter so the *next* failure (if any) starts from
|
||||
// a short delay again — we just had a real user interaction, not
|
||||
// an unattended-tab failure cascade.
|
||||
this.attempt = 0;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceData {
|
||||
@@ -306,11 +346,49 @@ export interface WorkspaceData {
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
/** visibilitychange / pageshow handler. Mobile browsers (iOS Safari,
|
||||
* Chrome on Android in deep-sleep) silently drop the WebSocket when
|
||||
* the tab is backgrounded — the in-page `onclose` fires very late or
|
||||
* never. Without this listener, the canvas appears frozen after the
|
||||
* user backgrounds the PWA and returns to it: status events, agent
|
||||
* messages, and cross-device chat broadcast don't arrive until a
|
||||
* manual refresh (#223 / #228).
|
||||
*
|
||||
* Both events are wired: `visibilitychange` covers tab-switch on a
|
||||
* live page; `pageshow` covers Safari's bfcache restore, where the
|
||||
* page comes back from cache without firing visibilitychange. */
|
||||
function onPageWake() {
|
||||
// document is undefined in SSR; the listener never installs there,
|
||||
// but defensively guard anyway in case this code is run via a test
|
||||
// harness that doesn't shim it.
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
socket?.wake();
|
||||
}
|
||||
let visibilityHandlerInstalled = false;
|
||||
function installVisibilityHandler() {
|
||||
if (visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.addEventListener("visibilitychange", onPageWake);
|
||||
// `pageshow` with `event.persisted === true` is the bfcache restore
|
||||
// signal — relevant on iOS Safari. We don't need to inspect
|
||||
// `persisted` because waking an OPEN socket is a no-op.
|
||||
window.addEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = true;
|
||||
}
|
||||
function uninstallVisibilityHandler() {
|
||||
if (!visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.removeEventListener("visibilitychange", onPageWake);
|
||||
window.removeEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = false;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
if (!socket) {
|
||||
socket = new ReconnectingSocket(WS_URL);
|
||||
}
|
||||
socket.connect();
|
||||
installVisibilityHandler();
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
@@ -318,4 +396,14 @@ export function disconnectSocket() {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
uninstallVisibilityHandler();
|
||||
}
|
||||
|
||||
/** Manually trigger the visibility-wake path. Exported so the test suite
|
||||
* can exercise `ReconnectingSocket.wake()` without depending on a
|
||||
* jsdom DOM (the rest of this file's tests run under the node env).
|
||||
* Real-world callers don't need this — the visibility/pageshow listener
|
||||
* drives it. */
|
||||
export function wakeSocket() {
|
||||
socket?.wake();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user