diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index 330aa18bad..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,11 @@ export default class View { } delayedDisconnected() { + if (this.disconnectedTimer != null) { + return; + } this.disconnectedTimer = setTimeout(() => { + this.disconnectedTimer = null; this.execAll(this.binding("disconnected")); }, this.liveSocket.disconnectedTimeout); } diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index a97227764c..8ff5799a45 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -1444,6 +1444,81 @@ 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 keep 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(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"); + }); + test("join", async () => { liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM();