From 17886f1b35c6321f83b1b81e25bde858b6de35fd Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 20:41:52 +0000 Subject: [PATCH 1/4] fix: keep reloading after a cancelled "Leave site?" dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `beforeunload` set `isUnloading` and nothing ever cleared it. The event only means the page *may* be leaving, though: any listener can cancel it, and when the user then clicks "Cancel" the page stays with the flag set, so `reloadApp` returns early for the rest of its life and every hot update and live reload is silently dropped until a manual refresh. Form guards and editor-integration tools set such a listener routinely. There is no event that announces a cancelled unload. Measured in Chrome, one fires `beforeunload`, `blur` and `focus` — and a confirmed unload fires those three too, then `pagehide`. So `pagehide` is what separates them, but it only arrives once the next document has loaded: with the response delayed 1s and 4s, `beforeunload`-to-`pagehide` measured 1023ms and 4024ms. Its absence therefore cannot be told from a slow navigation at any fixed moment. The suppression is now a grace period instead of permanent, with `pagehide` making it permanent once the page really is going. The length trades the two failures against each other — too short and a slow navigation can still be interrupted by a reload, which is what #544 added this for; too long and the cancelled dialog keeps dropping updates. `pageshow` clears it as well, for the same bug reached the other way: navigate away, come back, and the page restored from the back/forward cache is the same script with the flag left set. Verified end to end in Chrome against the real server, cancelling the dialog and then editing a file: on `main` the client logs "App updated. Recompiling..." and stops there; with this change it goes on to "App updated. Reloading..." and the page reloads. Fixes #5571 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .../fix-beforeunload-cancel-blocks-reload.md | 5 ++ client-src/index.js | 33 ++++++++ test/client/index.test.js | 75 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 .changeset/fix-beforeunload-cancel-blocks-reload.md diff --git a/.changeset/fix-beforeunload-cancel-blocks-reload.md b/.changeset/fix-beforeunload-cancel-blocks-reload.md new file mode 100644 index 0000000000..23bab78542 --- /dev/null +++ b/.changeset/fix-beforeunload-cancel-blocks-reload.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Keep hot and live reload working after a cancelled "Leave site?" dialog. diff --git a/client-src/index.js b/client-src/index.js index cf6f88fc3a..c8c7adb9f1 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -276,8 +276,41 @@ const logEnabledFeatures = (features) => { logEnabledFeatures(enabledFeatures); +// `beforeunload` only means the page *may* be leaving: any listener can cancel +// it, and a cancelled unload fires no event of its own to say so. `pagehide` +// means it really is going, but only arrives once the next document loads, so +// nothing tells the two apart at a fixed moment — hence a grace period rather +// than suppressing until `pagehide`. Too short and a slow navigation can still +// be interrupted by a reload (#544); too long and a cancelled dialog leaves +// updates silently dropped (#5571). +const UNLOAD_GRACE_PERIOD = 2000; + +/** @type {ReturnType | undefined} */ +let unloadGraceTimer; + self.addEventListener("beforeunload", () => { status.isUnloading = true; + + clearTimeout(unloadGraceTimer); + + unloadGraceTimer = setTimeout(() => { + status.isUnloading = false; + }, UNLOAD_GRACE_PERIOD); +}); + +// The page really is going now, so stop reloading it for good. +self.addEventListener("pagehide", () => { + clearTimeout(unloadGraceTimer); + + status.isUnloading = true; +}); + +// Restored from the back/forward cache: the same script keeps running, so a +// flag left set by the navigation away would block updates from here on. +self.addEventListener("pageshow", () => { + clearTimeout(unloadGraceTimer); + + status.isUnloading = false; }); const overlay = diff --git a/test/client/index.test.js b/test/client/index.test.js index bc155e067b..9c2eea09d7 100644 --- a/test/client/index.test.js +++ b/test/client/index.test.js @@ -320,4 +320,79 @@ describe("index", () => { t.assert.snapshot(log.log.info.mock.calls[1][0]); t.assert.snapshot(sendMessage.mock.calls[0][0]); }); + + describe("unloading", () => { + // Longer than the client's own grace period, so a suppression that should + // have lapsed has had every chance to. + const AFTER_GRACE_PERIOD = 2500; + + const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + /** + * Drives a live reload to the point where only `isUnloading` can stop it. + * @returns {Promise} whether the page was reloaded + */ + async function pageReloads() { + self.location.reload.mockReset(); + + onSocketMessage.liveReload(); + onSocketMessage.hash(`hash-${Math.random()}`); + onSocketMessage.ok(); + + // The live reload path polls for a usable window on an interval rather + // than reloading straight away, so a reload that is coming needs a few + // turns, and one that is suppressed never arrives at all. + for (let i = 0; i < 20; i++) { + if (self.location.reload.mock.calls.length > 0) return true; + + await sleep(5); + } + + return false; + } + + it("should reload with no unload in progress", async () => { + expect(await pageReloads()).toBe(true); + }); + + it("should not reload while the page may be leaving", async () => { + self.dispatchEvent(new Event("beforeunload")); + + expect(await pageReloads()).toBe(false); + }); + + it("should reload again once a cancelled unload has lapsed", async () => { + self.dispatchEvent(new Event("beforeunload")); + + expect(await pageReloads()).toBe(false); + + // No `pagehide` follows a cancelled dialog, so this is the page staying. + await sleep(AFTER_GRACE_PERIOD); + + expect(await pageReloads()).toBe(true); + }); + + it("should not reload once the page is really gone", async () => { + self.dispatchEvent(new Event("beforeunload")); + self.dispatchEvent(new Event("pagehide")); + + await sleep(AFTER_GRACE_PERIOD); + + expect(await pageReloads()).toBe(false); + }); + + it("should reload after a restore from the back/forward cache", async () => { + self.dispatchEvent(new Event("beforeunload")); + self.dispatchEvent(new Event("pagehide")); + + expect(await pageReloads()).toBe(false); + + self.dispatchEvent(new Event("pageshow")); + + expect(await pageReloads()).toBe(true); + }); + }); }); From f6a070686623c96681aa58f5a4511870af205419 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 19 Sep 2026 12:46:06 +0000 Subject: [PATCH 2/4] test: stop the overlay test waiting on a state the client undoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `overlay.test.js:447` timed out for its full two minutes on macOS/Node 26, waiting for `#webpack-dev-server-client-overlay` to become hidden after a second, different build error is written. That hidden state is not one the test can rely on seeing. The client dismisses the overlay when the `invalid` message announces the rebuild and shows it again from the errors that rebuild produces, so it exists only for the length of the build in between — and only if `invalid` arrives separately at all. Waiting for it is a race, and losing it costs the whole timeout rather than reporting anything useful. It is also not what the test is about. Its name says "then show other error", so what matters is the second error reaching the overlay, whether or not the overlay flickered getting there. It now waits for the overlay's content to change instead, which holds however the client gets there. The snapshots are untouched, so the same page and overlay HTML is still asserted. Point the second write at the same content as the first, so no new error is produced, and the test fails — it is the wait that changed, not what is being checked. The file's six other `hidden: true` waits are left alone: those follow an Escape keypress or a write of the good fixture, where hidden is where the overlay stays. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- test/e2e/overlay.test.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index a175beec83..dd417b0315 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -442,18 +442,29 @@ describe("overlay", () => { }), ); + const firstErrorOverlayHtml = overlayHtml; + fs.writeFileSync(pathToOverlayFixture, "`;a"); - await page.waitForSelector("#webpack-dev-server-client-overlay", { - hidden: true, - }); - await page.waitForSelector("#webpack-dev-server-client-overlay"); + // The `invalid` message announcing the rebuild dismisses the overlay and + // the errors it produces show it again, so the hidden state in between + // lasts only as long as that build and is not reliably observable — + // waiting for it is a race this test loses as a two minute timeout. What + // is under test is the second, different error reaching the overlay. + await waitForExpect(async () => { + overlayHandle = await page.$("#webpack-dev-server-client-overlay"); - overlayHandle = await page.$("#webpack-dev-server-client-overlay"); - pageHtml = await page.evaluate(() => document.body.outerHTML); + expect(overlayHandle).not.toBeNull(); + + overlayFrame = await overlayHandle.contentFrame(); + overlayHtml = await overlayFrame.evaluate( + () => document.body.outerHTML, + ); - overlayFrame = await overlayHandle.contentFrame(); - overlayHtml = await overlayFrame.evaluate(() => document.body.outerHTML); + expect(overlayHtml).not.toBe(firstErrorOverlayHtml); + }, 60000); + + pageHtml = await page.evaluate(() => document.body.outerHTML); t.assert.snapshot( await format(pageHtml, { From 513b9ebc93125c321c9b2e6e9d105beb359e4a33 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 19 Sep 2026 13:15:32 +0000 Subject: [PATCH 3/4] test: read the overlay page once the fix has settled, not mid reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS/Node 24 failed with "Execution context was destroyed, most likely because of a navigation" at `overlay.test.js:486`, 15s in rather than on a timeout. Restoring the good fixture produces a build that succeeds, so the client does two things in sequence: it dismisses the overlay when `invalid` announces the rebuild, then live reloads the page when the build lands. The test read the page on the first of those — `waitForSelector(hidden)` resolves on the dismiss — which leaves the read racing the reload that follows, and losing it destroys the execution context underneath `page.evaluate`. Both tests that restore the fixture now read the page until it settles, with the same retry covering the reload, instead of reading once the moment the overlay goes. Same shape as the previous commit: the wait was keyed on a transient step rather than the state being asserted. Snapshots are untouched again, so the same page HTML is asserted, and the overlay suite passes three runs in a row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- test/e2e/overlay.test.js | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index dd417b0315..96faf9396c 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -374,14 +374,19 @@ describe("overlay", () => { fs.writeFileSync(pathToOverlayFixture, overlayFixtureCode); - await page.waitForSelector("#webpack-dev-server-client-overlay", { - hidden: true, - }); + // This fixture builds, so the client dismisses the overlay when `invalid` + // announces the rebuild and then live reloads the page once that build + // lands. Reading the page in between the two is what destroys the + // execution context mid-evaluate, so read it until it settles rather than + // once, the moment the overlay goes. + await waitForExpect(async () => { + overlayHandle = await page.$("#webpack-dev-server-client-overlay"); - pageHtml = await page.evaluate(() => document.body.outerHTML); - overlayHandle = await page.$("#webpack-dev-server-client-overlay"); + expect(overlayHandle).toBeNull(); + + pageHtml = await page.evaluate(() => document.body.outerHTML); + }, 60000); - expect(overlayHandle).toBeNull(); t.assert.snapshot( await format(pageHtml, { parser: "html", @@ -479,14 +484,19 @@ describe("overlay", () => { fs.writeFileSync(pathToOverlayFixture, overlayFixtureCode); - await page.waitForSelector("#webpack-dev-server-client-overlay", { - hidden: true, - }); + // This fixture builds, so the client dismisses the overlay when `invalid` + // announces the rebuild and then live reloads the page once that build + // lands. Reading the page in between the two is what destroys the + // execution context mid-evaluate, so read it until it settles rather than + // once, the moment the overlay goes. + await waitForExpect(async () => { + overlayHandle = await page.$("#webpack-dev-server-client-overlay"); - pageHtml = await page.evaluate(() => document.body.outerHTML); - overlayHandle = await page.$("#webpack-dev-server-client-overlay"); + expect(overlayHandle).toBeNull(); + + pageHtml = await page.evaluate(() => document.body.outerHTML); + }, 60000); - expect(overlayHandle).toBeNull(); t.assert.snapshot( await format(pageHtml, { parser: "html", From 4990db84c672b6f6b75c5ad8f1d9c0994f90f29e Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 19 Sep 2026 13:32:52 +0000 Subject: [PATCH 4/4] test: snapshot the overlay page after the fix reloads it, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CodeRabbit` pointed out that the previous commit's wait can settle before the live reload, so the page it snapshots is the document from before the fix. Measured, and it is right: marking the document and reading it the moment the overlay goes always caught the pre-reload one. The HTML happens to be identical either way here, so nothing was flaky or wrong, but the test claims to check the page after the fix and was checking the page before it — and reading on the dismiss is also what left the evaluate racing the reload in the first place. Both tests now mark the document before restoring the fixture and wait for one that no longer carries the mark, so the capture is of the reloaded page. Point that wait at a value no document will have and both fail, where before the same edit left them passing. Snapshots are untouched, and the suite passes three runs in a row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- test/e2e/overlay.test.js | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 96faf9396c..ac719643c8 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -372,14 +372,25 @@ describe("overlay", () => { }), ); + // Marks this document so the wait below can tell it from the reloaded one. + await page.evaluate(() => { + globalThis.documentFromBeforeTheFix = true; + }); + fs.writeFileSync(pathToOverlayFixture, overlayFixtureCode); // This fixture builds, so the client dismisses the overlay when `invalid` // announces the rebuild and then live reloads the page once that build - // lands. Reading the page in between the two is what destroys the - // execution context mid-evaluate, so read it until it settles rather than - // once, the moment the overlay goes. + // lands. Reading on the dismiss reads the document from before the fix + // and races the reload that follows, which is what destroyed the + // execution context mid-evaluate. Wait for the reloaded page instead. await waitForExpect(async () => { + const reloaded = await page.evaluate( + () => globalThis.documentFromBeforeTheFix === undefined, + ); + + expect(reloaded).toBe(true); + overlayHandle = await page.$("#webpack-dev-server-client-overlay"); expect(overlayHandle).toBeNull(); @@ -482,14 +493,25 @@ describe("overlay", () => { }), ); + // Marks this document so the wait below can tell it from the reloaded one. + await page.evaluate(() => { + globalThis.documentFromBeforeTheFix = true; + }); + fs.writeFileSync(pathToOverlayFixture, overlayFixtureCode); // This fixture builds, so the client dismisses the overlay when `invalid` // announces the rebuild and then live reloads the page once that build - // lands. Reading the page in between the two is what destroys the - // execution context mid-evaluate, so read it until it settles rather than - // once, the moment the overlay goes. + // lands. Reading on the dismiss reads the document from before the fix + // and races the reload that follows, which is what destroyed the + // execution context mid-evaluate. Wait for the reloaded page instead. await waitForExpect(async () => { + const reloaded = await page.evaluate( + () => globalThis.documentFromBeforeTheFix === undefined, + ); + + expect(reloaded).toBe(true); + overlayHandle = await page.$("#webpack-dev-server-client-overlay"); expect(overlayHandle).toBeNull();