From 40577ee8302d9fe09bfa48403ee07dbd2214ad00 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Mon, 5 Oct 2026 22:04:18 +0200 Subject: [PATCH 1/3] Clear pending disconnected timer before scheduling another --- assets/js/phoenix_live_view/view.ts | 1 + assets/test/view_test.ts | 49 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index 330aa18bad..62c9714392 100644 --- a/assets/js/phoenix_live_view/view.ts +++ b/assets/js/phoenix_live_view/view.ts @@ -1453,6 +1453,7 @@ export default class View { } delayedDisconnected() { + this.disconnectedTimer != null && clearTimeout(this.disconnectedTimer); this.disconnectedTimer = setTimeout(() => { this.execAll(this.binding("disconnected")); }, this.liveSocket.disconnectedTimeout); diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index a97227764c..1172cb18ab 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -1444,6 +1444,55 @@ describe("View", function () { done(); }); + test("reconnecting cancels disconnected commands after repeated errors", () => { + jest.useFakeTimers(); + liveSocket = new LiveSocket("/live", Socket, { disconnectedTimeout: 1000 }); + const el = document.querySelector("[data-phx-session]")!; + const status: HTMLElement = el.querySelector("#status")!; + const view = simulateJoinedView(el, liveSocket); + const execAll = jest.spyOn(view, "execAll"); + const classes = [ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]; + + view.displayError(classes); + jest.advanceTimersByTime(100); + view.displayError(classes); + jest.advanceTimersByTime(100); + view.hideLoader(); + jest.runAllTimers(); + + expect(execAll).toHaveBeenCalledWith("phx-connected"); + expect(execAll).not.toHaveBeenCalledWith("phx-disconnected"); + expect(status.style.display).toBe("none"); + expect(el.classList.contains("phx-connected")).toBeTruthy(); + }); + + test("repeated errors replace the pending disconnected timer", () => { + jest.useFakeTimers(); + liveSocket = new LiveSocket("/live", Socket, { disconnectedTimeout: 1000 }); + const el = document.querySelector("[data-phx-session]")!; + const view = simulateJoinedView(el, liveSocket); + const execAll = jest.spyOn(view, "execAll"); + const classes = [ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]; + + view.displayError(classes); + jest.advanceTimersByTime(100); + view.displayError(classes); + jest.advanceTimersByTime(900); + expect(execAll).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + expect(execAll).toHaveBeenCalledTimes(1); + expect(execAll).toHaveBeenCalledWith("phx-disconnected"); + }); + test("join", async () => { liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM(); From 43941460d226cb77ea26ce0de6d07650d3d69bd8 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 9 Oct 2026 17:45:29 +0200 Subject: [PATCH 2/3] Update assets/js/phoenix_live_view/view.ts --- assets/js/phoenix_live_view/view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index 62c9714392..e47817ae4a 100644 --- a/assets/js/phoenix_live_view/view.ts +++ b/assets/js/phoenix_live_view/view.ts @@ -1453,8 +1453,9 @@ export default class View { } delayedDisconnected() { - this.disconnectedTimer != null && clearTimeout(this.disconnectedTimer); + if (this.disconnectedTimer != null) { return }; this.disconnectedTimer = setTimeout(() => { + this.disconnectedTimer = null; this.execAll(this.binding("disconnected")); }, this.liveSocket.disconnectedTimeout); } From 0a9f74a0e1b400c951e8f77b0a11a73ba56fd6f8 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 9 Oct 2026 17:49:37 +0200 Subject: [PATCH 3/3] adjust test --- assets/js/phoenix_live_view/view.ts | 5 ++++- assets/test/view_test.ts | 30 +++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index e47817ae4a..13091263af 100644 --- a/assets/js/phoenix_live_view/view.ts +++ b/assets/js/phoenix_live_view/view.ts @@ -328,6 +328,7 @@ export default class View { hideLoader() { this.loaderTimer != null && clearTimeout(this.loaderTimer); this.disconnectedTimer != null && clearTimeout(this.disconnectedTimer); + this.disconnectedTimer = null; this.setContainerClasses(PHX_CONNECTED_CLASS); this.execAll(this.binding("connected")); } @@ -1453,7 +1454,9 @@ export default class View { } delayedDisconnected() { - if (this.disconnectedTimer != null) { return }; + if (this.disconnectedTimer != null) { + return; + } this.disconnectedTimer = setTimeout(() => { this.disconnectedTimer = null; this.execAll(this.binding("disconnected")); diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index 1172cb18ab..8ff5799a45 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -1470,7 +1470,7 @@ describe("View", function () { expect(el.classList.contains("phx-connected")).toBeTruthy(); }); - test("repeated errors replace the pending disconnected timer", () => { + test("repeated errors keep the pending disconnected timer", () => { jest.useFakeTimers(); liveSocket = new LiveSocket("/live", Socket, { disconnectedTimeout: 1000 }); const el = document.querySelector("[data-phx-session]")!; @@ -1485,10 +1485,36 @@ describe("View", function () { view.displayError(classes); jest.advanceTimersByTime(100); view.displayError(classes); - jest.advanceTimersByTime(900); + jest.advanceTimersByTime(899); expect(execAll).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(execAll).toHaveBeenCalledTimes(1); + expect(execAll).toHaveBeenCalledWith("phx-disconnected"); + + jest.runAllTimers(); + expect(execAll).toHaveBeenCalledTimes(1); + }); + + test("errors after reconnecting schedule a new disconnected timer", () => { + jest.useFakeTimers(); + liveSocket = new LiveSocket("/live", Socket, { disconnectedTimeout: 1000 }); + const el = document.querySelector("[data-phx-session]")!; + const view = simulateJoinedView(el, liveSocket); + const execAll = jest.spyOn(view, "execAll"); + const classes = [ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]; + + view.displayError(classes); jest.advanceTimersByTime(100); + view.hideLoader(); + execAll.mockClear(); + + view.displayError(classes); + jest.advanceTimersByTime(1000); expect(execAll).toHaveBeenCalledTimes(1); expect(execAll).toHaveBeenCalledWith("phx-disconnected"); });