From d6db429490a0f1dd5a1eb989c62bf6e4b4b24984 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 13 Jul 2026 23:05:43 -0500 Subject: [PATCH] feat(client): add disconnect() to close the SSE connection Expose a way to close the EventSource for the current path and stop the reconnection watchdog (e.g. before tearing the page down). The cached wrapper is dropped so a later setOptionsAndConnect() opens a fresh connection. Ref webpack/webpack-hot-middleware#367 --- README.md | 4 ++++ client-src/index.js | 25 ++++++++++++++++++++++++- test/client.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 676495526..df5edf9ba 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,10 @@ hotClient.useCustomOverlay({ // Connect manually when `autoConnect=false`. Accepts the same option keys as // the query-string API above. hotClient.setOptionsAndConnect({ path: "/__hmr" }); + +// Close the SSE connection and stop reconnecting (e.g. before tearing the +// page down). A later `setOptionsAndConnect` call opens a fresh connection. +hotClient.disconnect(); ``` ## API diff --git a/client-src/index.js b/client-src/index.js index 106ab1f8e..01663785a 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -105,9 +105,17 @@ function createEventSourceWrapper() { } }; - const handleDisconnect = () => { + /** + * Close the connection and stop the activity timer without scheduling a + * reconnection. + */ + const close = () => { clearInterval(timer); source.close(); + }; + + const handleDisconnect = () => { + close(); setTimeout(init, /** @type {number} */ (options.timeout)); }; @@ -135,6 +143,7 @@ function createEventSourceWrapper() { addMessageListener(fn) { listeners.push(fn); }, + close, }; } @@ -180,6 +189,20 @@ export function setOptionsAndConnect(overrides) { connect(); } +/** + * Close the SSE connection for the current path and stop reconnecting. A + * later `setOptionsAndConnect` call opens a fresh connection. + */ +export function disconnect() { + const path = /** @type {string} */ (options.path); + const wrappers = window[WRAPPER_KEY]; + + if (wrappers && wrappers[path]) { + wrappers[path].close(); + delete wrappers[path]; + } +} + // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ diff --git a/test/client.test.js b/test/client.test.js index e310b30b0..3adda0f88 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -599,6 +599,32 @@ describe("client", () => { jest.advanceTimersByTime(20 * 1000); expect(EventSourceStub.instances).toHaveLength(2); }); + + it("disconnect() closes the connection and does not reconnect", () => { + jest.useFakeTimers({ doNotFake: ["nextTick"] }); + delete globalThis.__wdmEventSourceWrapper; + EventSourceStub.instances.length = 0; + client = loadClient(); + + const [first] = EventSourceStub.instances; + client.disconnect(); + + expect(first.closed).toBe(true); + // Neither the watchdog nor a reconnect timer may re-open it. + jest.advanceTimersByTime(60 * 1000); + expect(EventSourceStub.instances).toHaveLength(1); + }); + + it("disconnect() drops the cached wrapper so a new connect starts fresh", () => { + client.disconnect(); + expect( + globalThis.__wdmEventSourceWrapper["/__webpack_hmr"], + ).toBeUndefined(); + + client.setOptionsAndConnect({}); + expect(EventSourceStub.instances).toHaveLength(2); + expect(EventSourceStub.lastInstance().closed).toBe(false); + }); }); describe("with logging option", () => {