diff --git a/CHANGELOG.md b/CHANGELOG.md index ba43343..51cdf00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to @livetemplate/client will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **A failed reconnect no longer freezes the page until a reload (#160).** When + a live socket dropped and the reconnect ran while the network was still down + (a phone waking from sleep, a Wi-Fi blip), the client fell back to HTTP mode + and never tried the WebSocket again — the heartbeat and visibility paths both + skip HTTP mode — so server pushes stopped until the user reloaded. The client + now keeps retrying: on the next heartbeat tick (then backing off, capped at + 5 minutes), on a return to visible, on `pageshow`, and on the browser's + `online` event. Actions still go over HTTP in the meantime. A server that + reports no WebSocket at all, or a socket that fails on the very first connect, + still falls back to HTTP for good, as before. An app that opted out of the + heartbeat (`data-lvt-heartbeat-ms="0"`) gets no timed retries — it recovers + on `online`, a return to visible, or `pageshow` only. + ## [v0.25.1] - 2026-09-06 ### Added diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 7760d75..1728b99 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -189,6 +189,19 @@ export class LiveTemplateClient { private visibilityHandler: (() => void) | null = null; private pageshowHandler: ((e: PageTransitionEvent) => void) | null = null; private visibilityReconnectTimer: ReturnType | null = null; + private onlineHandler: (() => void) | null = null; + + // Lost-WebSocket recovery (#160): a RE-connect that failed because the + // network was down. Actions keep going over HTTP (useHTTP stays true), but the + // heartbeat tick, a return to visible, pageshow and `online` retry the socket + // — before #160 every one of those skipped HTTP mode and the page froze. + private static readonly MAX_WS_RETRY_MS = 5 * 60 * 1000; + private wsRetryFailures: number = 0; // > 0 ⇔ the socket is lost + private wsRetryAt: number = 0; + + private get wsLost(): boolean { + return this.wsRetryFailures > 0; + } // Liveness heartbeat: an app-level ping/pong the client drives to detect a // dead OR zombie socket (a socket that still reports OPEN but whose TCP is @@ -815,6 +828,7 @@ export class LiveTemplateClient { this.useHTTP = false; this.hiddenAt = 0; this.reconnecting = false; + this.clearWSLost(); this.stopHeartbeat(); this.teardownVisibilityReconnect(); this.uploadHandler.revokePreviews(); @@ -895,6 +909,13 @@ export class LiveTemplateClient { } return; } + // A lost socket has no hiddenAt (that is only recorded while OPEN) and no + // live connection to protect, so any return to visible retries it. + if (this.wsLost) { + this.hiddenAt = 0; + this.scheduleVisibilityReconnect(); + return; + } if (this.hiddenAt === 0) return; const elapsed = Date.now() - this.hiddenAt; this.hiddenAt = 0; @@ -911,8 +932,16 @@ export class LiveTemplateClient { } }; + // The network came back: retry a lost socket now rather than waiting out + // the backoff. For an app that opted out of the heartbeat, this and a + // return to visible are the only retries. + this.onlineHandler = () => { + if (this.wsLost) this.scheduleVisibilityReconnect(); + }; + document.addEventListener("visibilitychange", this.visibilityHandler); window.addEventListener("pageshow", this.pageshowHandler); + window.addEventListener("online", this.onlineHandler); } private teardownVisibilityReconnect(): void { @@ -929,6 +958,10 @@ export class LiveTemplateClient { window.removeEventListener("pageshow", this.pageshowHandler); this.pageshowHandler = null; } + if (this.onlineHandler) { + window.removeEventListener("online", this.onlineHandler); + this.onlineHandler = null; + } this.visibilityHandlerAttached = false; } @@ -957,11 +990,13 @@ export class LiveTemplateClient { // the underlying TCP connection is dead. Always reconnecting after // a long background is cheap (morphdom diffs produce no DOM changes // on a healthy connection) and handles the zombie case. + // A lost socket (wsLost) passes too: it is in HTTP mode with no + // transport, which would otherwise read as "intentionally disconnected". if ( this.wrapperElement && - !this.useHTTP && !this.reconnecting && - this.webSocketManager.getReadyState() !== undefined + (this.wsLost || + (!this.useHTTP && this.webSocketManager.getReadyState() !== undefined)) ) { this.performVisibilityReconnect(); } @@ -991,6 +1026,12 @@ export class LiveTemplateClient { this.useHTTP = !result.usingWebSocket; + // "failed": the server speaks WS but the socket could not open (network + // down) — keep retrying. Live, or "unavailable" (a genuine, permanent + // fallback): nothing to retry. + if (result.reason === "failed") this.markWSLost(); + else this.clearWSLost(); + if (this.useHTTP) { this.ws = null; if (result.initialState && this.wrapperElement) { @@ -1001,11 +1042,33 @@ export class LiveTemplateClient { this.wrapperElement?.dispatchEvent(new Event("lvt:reconnected")); } catch (err) { this.logger.error("Visibility reconnect failed:", err); + if (this.wsLost) this.markWSLost(); // still lost: advance the backoff } finally { this.reconnecting = false; } } + // markWSLost records one more failed retry of a lost socket and schedules the + // next timed one: after failure 1 the retry is due on the next tick; after + // failure n ≥ 2 it waits heartbeatMs × 2^(n-1) (2×, 4×, …), capped at + // MAX_WS_RETRY_MS. + private markWSLost(): void { + this.wsRetryFailures++; + const wait = + this.wsRetryFailures <= 1 + ? 0 + : Math.min( + this.heartbeatMs * 2 ** (this.wsRetryFailures - 1), + LiveTemplateClient.MAX_WS_RETRY_MS + ); + this.wsRetryAt = Date.now() + wait; + } + + private clearWSLost(): void { + this.wsRetryFailures = 0; + this.wsRetryAt = 0; + } + // startHeartbeat arms the liveness interval (idempotent; no-op unless // heartbeatMs > 0). Called on every (re)connect from onConnected. private startHeartbeat(): void { @@ -1033,12 +1096,19 @@ export class LiveTemplateClient { // (the visibility path owns that case), mid-reconnect, or in HTTP mode. private heartbeatTick(): void { if ( - this.useHTTP || this.reconnecting || (typeof document !== "undefined" && document.hidden) ) { return; } + // A lost socket has no transport to ping; the tick is its retry clock + // instead. Checked before the useHTTP / no-transport early returns, which + // would otherwise skip it forever (#160). + if (this.wsLost) { + if (Date.now() >= this.wsRetryAt) void this.performVisibilityReconnect(); + return; + } + if (this.useHTTP) return; const readyState = this.webSocketManager.getReadyState(); // undefined ⇒ no transport (intentionally disconnected) → nothing to probe. if (readyState === undefined) return; diff --git a/tests/heartbeat.test.ts b/tests/heartbeat.test.ts index 409a829..7f08f26 100644 --- a/tests/heartbeat.test.ts +++ b/tests/heartbeat.test.ts @@ -35,6 +35,12 @@ class MockWebSocket { this.readyState = MockWebSocket.OPEN; this.onopen?.(new Event("open")); } + // The network is down: the open errors, then closes, before it ever opened. + simulateFail() { + this.readyState = MockWebSocket.CLOSED; + this.onerror?.(new Event("error")); + this.onclose?.(new CloseEvent("close", { code: 1006 })); + } // Deliver a server → client message through the transport's onmessage. deliver(obj: unknown) { this.onmessage?.( @@ -44,12 +50,15 @@ class MockWebSocket { } let mockSockets: MockWebSocket[] = []; +let socketCreatedAt: number[] = []; function installMockWebSocket() { mockSockets = []; + socketCreatedAt = []; const WS = jest.fn().mockImplementation((url: string) => { const sock = new MockWebSocket(url); mockSockets.push(sock); + socketCreatedAt.push(Date.now()); return sock; }) as any; WS.CONNECTING = 0; @@ -59,7 +68,12 @@ function installMockWebSocket() { (global as any).WebSocket = WS; } +// wsHeader is what the HEAD probe reports; flip it to "disabled" to model a +// server that genuinely has no WebSocket. +let wsHeader = "enabled"; + function installFetchStub() { + wsHeader = "enabled"; if (typeof (globalThis as any).fetch !== "function") { (globalThis as any).fetch = () => {}; } @@ -68,12 +82,17 @@ function installFetchStub() { .mockImplementation((...args: unknown[]) => { const opts = args[1] as any; if (opts?.method === "HEAD") { - return Promise.resolve( - new Response(null, { - status: 200, - headers: { "X-LiveTemplate-WebSocket": "enabled" }, - }) - ); + // A minimal Response stand-in: jsdom has no global Response, and a + // throwing stub would make the probe's catch assume WS and never read + // the header at all. + return Promise.resolve({ + ok: true, + status: 200, + headers: { + get: (k: string) => + k === "X-LiveTemplate-WebSocket" ? wsHeader : null, + }, + }); } return Promise.resolve(new Response("{}", { status: 200 })); }); @@ -88,6 +107,39 @@ function createWrapper(): HTMLDivElement { const HB = 10000; // heartbeat interval; pong deadline = max(2000, HB/2) = 5000 +// Connect a client with an explicit heartbeat interval (set pre-connect, exactly +// as autoInit does from data-lvt-heartbeat-ms; 0 opts OUT, since the heartbeat +// is ON by default) and open its first socket. +async function connectClient( + heartbeatMs: number +): Promise<{ client: LiveTemplateClient; wrapper: HTMLDivElement }> { + const wrapper = createWrapper(); + const client = new LiveTemplateClient({ logLevel: "error" }); + (client as any).heartbeatMs = heartbeatMs; + const p = client.connect(); + await jest.advanceTimersByTimeAsync(0); // resolve HEAD fetch + mockSockets[0]?.simulateOpen(); + await p; + return { client, wrapper }; +} + +function setHidden(hidden: boolean) { + Object.defineProperty(document, "hidden", { + value: hidden, + writable: true, + configurable: true, + }); +} + +function fireVisibilityChange(hidden: boolean) { + setHidden(hidden); + document.dispatchEvent(new Event("visibilitychange")); +} + +function firePageShow(persisted: boolean) { + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted })); +} + describe("Liveness heartbeat", () => { let client: LiveTemplateClient; @@ -105,17 +157,8 @@ describe("Liveness heartbeat", () => { jest.restoreAllMocks(); }); - // Connect with an explicit heartbeat interval (set pre-connect, exactly as - // autoInit does from data-lvt-heartbeat-ms). enabled=false opts OUT (0), since - // the heartbeat is ON by default now. async function connectWithHeartbeat(enabled: boolean): Promise { - createWrapper(); - client = new LiveTemplateClient({ logLevel: "error" }); - (client as any).heartbeatMs = enabled ? HB : 0; - const connectPromise = client.connect(); - await jest.advanceTimersByTimeAsync(0); // resolve HEAD fetch - mockSockets[0]?.simulateOpen(); - await connectPromise; + ({ client } = await connectClient(enabled ? HB : 0)); } function pingsSent(sock: MockWebSocket): number { @@ -199,3 +242,190 @@ describe("Liveness heartbeat", () => { expect(mockSockets.length).toBe(1); }); }); + +// #160: a RE-connect that fails because the network is down used to latch HTTP +// mode forever — every self-heal path skipped useHTTP, so the page froze until a +// reload. Every assertion counts constructed sockets (an observable retry), so a +// gate that still blocks the lost state fails here rather than passing on a flag. +describe("Lost-socket recovery (#160)", () => { + let client: LiveTemplateClient; + let wrapper: HTMLDivElement; + + beforeEach(() => { + jest.useFakeTimers(); + document.body.replaceChildren(); + installMockWebSocket(); + installFetchStub(); + history.replaceState(null, "", "/test"); + setHidden(false); + }); + + afterEach(() => { + client?.disconnect(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + async function settle() { + for (let i = 0; i < 5; i++) await jest.advanceTimersByTimeAsync(0); + } + + async function connectLive(): Promise { + ({ client, wrapper } = await connectClient(HB)); + } + + // The live socket dies; the heartbeat's reconnect runs while the network is + // still down, so its new socket fails → the lost state. + async function loseSocket(): Promise { + mockSockets[0].readyState = MockWebSocket.CLOSED; + await jest.advanceTimersByTimeAsync(HB); // tick → reconnect + await settle(); // HEAD probe resolves → socket #2 constructed + expect(mockSockets.length).toBe(2); + mockSockets[1].simulateFail(); + await settle(); + expect((client as any).useHTTP).toBe(true); + expect((client as any).wsLost).toBe(true); + } + + // Lose the socket, then fail the next-tick retry too, so the next timed retry + // is 2×HB away: inside that window only an event trigger can explain a new + // socket. + async function loseSocketAndBurnRetry(): Promise { + await connectLive(); + await loseSocket(); + await jest.advanceTimersByTimeAsync(HB); + await settle(); + expect(mockSockets.length).toBe(3); + mockSockets[2].simulateFail(); + await settle(); + } + + it("retries the socket on the next tick and goes live again", async () => { + await connectLive(); + await loseSocket(); + const reconnected = jest.fn(); + wrapper.addEventListener("lvt:reconnected", reconnected); + + await jest.advanceTimersByTimeAsync(HB); + await settle(); + expect(mockSockets.length).toBe(3); // the retry + + mockSockets[2].simulateOpen(); + await settle(); + expect((client as any).useHTTP).toBe(false); + expect((client as any).wsLost).toBe(false); + expect(client.isReady()).toBe(true); + expect(reconnected).toHaveBeenCalled(); + }); + + it("backs off timed retries, capped at 5 min, and never stops", async () => { + await connectLive(); + await loseSocket(); + // Fail every retry for 45 min of fake time (the cap is reached at ~10 min). + let failed = 2; + for (let elapsed = 0; elapsed < 45 * 60 * 1000; elapsed += HB) { + await jest.advanceTimersByTimeAsync(HB); + await settle(); + while (failed < mockSockets.length) mockSockets[failed++].simulateFail(); + await settle(); + } + const retries = socketCreatedAt.slice(1); // #2 onward + const gaps = retries.slice(1).map((t, i) => t - retries[i]); + expect(gaps.length).toBeGreaterThan(3); + for (let i = 1; i < gaps.length; i++) { + expect(gaps[i]).toBeGreaterThanOrEqual(gaps[i - 1]); // non-decreasing + } + const cap = 5 * 60 * 1000; + for (const g of gaps) expect(g).toBeLessThanOrEqual(cap + HB); // tick granularity + expect(gaps[gaps.length - 1]).toBeGreaterThanOrEqual(cap); // reached the cap + // Bounded: 45 min is about a dozen attempts, not 270 ticks. + expect(retries.length).toBeLessThan(20); + }, 60000); + + it("does not retry a server that reports no WebSocket", async () => { + await connectLive(); + wsHeader = "disabled"; + mockSockets[0].readyState = MockWebSocket.CLOSED; + await jest.advanceTimersByTimeAsync(HB); + await settle(); + expect((client as any).useHTTP).toBe(true); + expect((client as any).wsLost).toBe(false); + await jest.advanceTimersByTimeAsync(HB * 20); + window.dispatchEvent(new Event("online")); + await jest.advanceTimersByTimeAsync(1000); + await settle(); + expect(mockSockets.length).toBe(1); // never another socket + }); + + it("keeps a first-connect failure in HTTP mode without retrying", async () => { + wrapper = createWrapper(); + client = new LiveTemplateClient({ logLevel: "error" }); + (client as any).heartbeatMs = HB; + const p = client.connect(); + await jest.advanceTimersByTimeAsync(0); + mockSockets[0].simulateFail(); + await p; + expect((client as any).useHTTP).toBe(true); + expect((client as any).wsLost).toBe(false); + await jest.advanceTimersByTimeAsync(HB * 10); + window.dispatchEvent(new Event("online")); + await jest.advanceTimersByTimeAsync(1000); + await settle(); + expect(mockSockets.length).toBe(1); + }); + + it("retries at once when the network comes back (online)", async () => { + await loseSocketAndBurnRetry(); + window.dispatchEvent(new Event("online")); + await jest.advanceTimersByTimeAsync(600); // the 500ms settle delay + await settle(); + expect(mockSockets.length).toBe(4); + }); + + it("ignores online while the socket is healthy", async () => { + await connectLive(); + window.dispatchEvent(new Event("online")); + await jest.advanceTimersByTimeAsync(1000); + await settle(); + expect(mockSockets.length).toBe(1); + }); + + it("retries on any return to visible, even under 3s", async () => { + await loseSocketAndBurnRetry(); + fireVisibilityChange(true); + await jest.advanceTimersByTimeAsync(1000); // well under 3s + fireVisibilityChange(false); + await jest.advanceTimersByTimeAsync(600); + await settle(); + expect(mockSockets.length).toBe(4); + }); + + it("retries on a bfcache pageshow", async () => { + await loseSocketAndBurnRetry(); + firePageShow(true); + await jest.advanceTimersByTimeAsync(600); + await settle(); + expect(mockSockets.length).toBe(4); + }); + + it("coalesces simultaneous triggers into one attempt", async () => { + await loseSocketAndBurnRetry(); + window.dispatchEvent(new Event("online")); + firePageShow(true); + fireVisibilityChange(false); + await jest.advanceTimersByTimeAsync(600); + await settle(); + expect(mockSockets.length).toBe(4); // one, not three + }); + + it("disconnect() clears the lost state and the online listener", async () => { + await connectLive(); + await loseSocket(); + client.disconnect(); + expect((client as any).wsLost).toBe(false); + window.dispatchEvent(new Event("online")); + await jest.advanceTimersByTimeAsync(HB * 3); + await settle(); + expect(mockSockets.length).toBe(2); + }); +}); diff --git a/tests/websocket.test.ts b/tests/websocket.test.ts index ca87b0e..a3dbcf9 100644 --- a/tests/websocket.test.ts +++ b/tests/websocket.test.ts @@ -616,10 +616,29 @@ describe("WebSocketManager connect", () => { const result = await connectPromise; expect(result.usingWebSocket).toBe(true); + expect(result.reason).toBeUndefined(); expect(onConnected).toHaveBeenCalledTimes(1); expect(onDisconnected).not.toHaveBeenCalled(); }); + // #160: the client retries a lost socket only for "failed" — the server + // speaks WS but the open did not complete — never for "unavailable". + it("reports reason 'unavailable' when the server says it has no WebSocket", async () => { + mockFetch + .mockReset() + .mockResolvedValueOnce({ + headers: { + get: (name: string) => + name === "X-LiveTemplate-WebSocket" ? "disabled" : null, + }, + }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ tree: null }) }); + const result = await makeManager({}).connect(); + expect(result.usingWebSocket).toBe(false); + expect(result.reason).toBe("unavailable"); + expect(mockSocket).toBeNull(); // no socket was ever attempted + }); + it("does NOT call onDisconnected when socket closes before opening (HTTP fallback)", async () => { const onConnected = jest.fn(); const onDisconnected = jest.fn(); @@ -654,6 +673,7 @@ describe("WebSocketManager connect", () => { const result = await connectPromise; expect(result.usingWebSocket).toBe(false); + expect(result.reason).toBe("failed"); expect(onConnected).not.toHaveBeenCalled(); // Critical: we never connected, so we must NOT signal "disconnected" // to downstream — that would falsely flip UI state. @@ -690,6 +710,7 @@ describe("WebSocketManager connect", () => { const result = await connectPromise; expect(result.usingWebSocket).toBe(false); + expect(result.reason).toBe("failed"); expect(onConnected).not.toHaveBeenCalled(); // Even though the catch block calls transport.disconnect() (which triggers // onClose), the hasConnected gate prevents a spurious onDisconnected. diff --git a/transport/websocket.ts b/transport/websocket.ts index 615a857..659e5e7 100644 --- a/transport/websocket.ts +++ b/transport/websocket.ts @@ -139,6 +139,11 @@ export interface WebSocketManagerConfig { export interface WebSocketConnectResult { usingWebSocket: boolean; initialState?: UpdateResponse | null; + // Why the connect fell back to HTTP (absent when usingWebSocket): + // "unavailable" — the server says it has no WebSocket; a permanent fallback. + // "failed" — the socket errored, closed before opening, or timed out; + // the server does speak WS, so a reconnect may retry it. + reason?: "unavailable" | "failed"; } export class WebSocketManager { @@ -168,7 +173,7 @@ export class WebSocketManager { ); if (!wsAvailable) { const initialState = await fetchInitialState(liveUrl, this.config.logger); - return { usingWebSocket: false, initialState }; + return { usingWebSocket: false, initialState, reason: "unavailable" }; } // Await onopen before resolving, so downstream setup runs with a ready @@ -277,7 +282,7 @@ export class WebSocketManager { this.transport?.disconnect(); this.transport = null; const initialState = await fetchInitialState(liveUrl, this.config.logger); - return { usingWebSocket: false, initialState }; + return { usingWebSocket: false, initialState, reason: "failed" }; } }