From 899cd336c4ca1a073a9b95310ae580f04c99907b Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:55:27 +0000 Subject: [PATCH] fix(overlay): keep a runtime error a successful build cannot vouch for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry that throws while it is still evaluating puts the overlay up before the transport has finished connecting. The catch-up sync arrives right behind it reporting no problems, and the reporter cleared the runtime slot on every clean build — so the overlay was dismissed milliseconds after appearing, and a page that threw on load looked fine. This is webpack-dev-server#5024, which webpack-dev-server fixes by splitting the two cases; this one had not. A build that merely succeeded says nothing about an error the page threw by itself, so it no longer clears those. A rebuild does, from `building`, because that replaces the code the error came from — matching webpack-dev-server's `BUILD_OK` and `DISMISS` split. What a rebuild looks like from the outside is unchanged. Also covers the `runtimeErrors` filter arriving through the resource query, which turned out to work already: encoded in both the layers webpack-dev-server uses, a filter round-trips intact. Writing that query by hand does not, since `encodeURIComponent` leaves `!` alone and webpack reads it as the inline-loader separator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/runtime-error-survives-build.md | 5 ++ client-src/globals.d.ts | 1 + client-src/index.js | 26 ++++++- test/e2e/overlay.test.js | 83 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 .changeset/runtime-error-survives-build.md diff --git a/.changeset/runtime-error-survives-build.md b/.changeset/runtime-error-survives-build.md new file mode 100644 index 000000000..2b21f7d92 --- /dev/null +++ b/.changeset/runtime-error-survives-build.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Keep an uncaught runtime error in the overlay when a build succeeds — a successful compilation says nothing about an error the page threw on its own, and it used to dismiss one raised moments earlier by an entry that threw while it was still evaluating. A rebuild still clears it, since that replaces the code the error came from diff --git a/client-src/globals.d.ts b/client-src/globals.d.ts index f2442ac71..99b0c11fa 100644 --- a/client-src/globals.d.ts +++ b/client-src/globals.d.ts @@ -10,6 +10,7 @@ declare module "ansi-html-community" { interface ClientReporter { cleanProblemsCache(name: string): void; + clearRuntimeProblems(): void; problems( type: "errors" | "warnings", obj: { errors: string[]; warnings: string[]; name?: string }, diff --git a/client-src/index.js b/client-src/index.js index da9a0e48d..876af591e 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -267,6 +267,7 @@ function getEventSourceWrapper() { /** * @returns {{ * cleanProblemsCache: (name: string) => void, + * clearRuntimeProblems: () => void, * problems: (type: "errors" | "warnings", obj: HMRPayload) => boolean, * success: (obj?: HMRPayload) => void, * useCustomOverlay: (customOverlay: EXPECTED_ANY) => void, @@ -361,10 +362,14 @@ function createReporter() { return false; } - // Clear only this client's problems and the runtime errors — other - // clients sharing the overlay keep theirs. + // Clear only this client's build problems — other clients sharing the + // overlay keep theirs, and runtime errors are not this event's to judge: a + // build that succeeded says nothing about an error the page threw on its + // own. Dropping them here dismissed an overlay raised milliseconds earlier + // by an entry that threw while it was still evaluating, because the + // handshake's catch-up sync arrives right behind it (webpack-dev-server + // #5024). A rebuild clears them instead, from `building` below. overlay.clear(""); - overlay.clear("runtime"); return true; }; @@ -409,6 +414,14 @@ function createReporter() { delete problemsByName[(obj && obj.name) || ""]; renderOverlay(); }, + clearRuntimeProblems() { + // No overlay configured, or a custom one that does not take sources. + if (!overlay || !overlay.clear) { + return; + } + + overlay.clear("runtime"); + }, useCustomOverlay(customOverlay) { overlay = customOverlay; }, @@ -441,6 +454,13 @@ function processMessage(obj) { obj.file ? ` (${obj.file} changed)` : "" }`, ); + // A rebuild replaces the code a runtime error came from, so the error + // stops being worth showing — unlike a build that merely succeeded, + // which says nothing about it. Not scoped to this client's bundle: a + // runtime error is the page's, not one compilation's. + if (reporter) { + reporter.clearRuntimeProblems(); + } if (options.progress && typeof document !== "undefined") { lastBuildingName = obj.name || ""; indicator.show( diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index b143ed6f4..b60a9db58 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -15,6 +15,8 @@ import { import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; +const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + jest.setTimeout(400000); describe("error overlay (browser)", () => { @@ -1103,6 +1105,87 @@ describe("error overlay parity with webpack-dev-server (browser)", () => { ).toBe(0); expect(await page.evaluate(() => globalThis.xss)).toBeUndefined(); }); + + it("keeps a runtime error the filter accepts and drops the one it rejects", async () => { + hotApp = await createHotApp({ + // Encoded in both layers webpack-dev-server uses: the function through + // `encodeURIComponent`, then the whole parameter through + // `URLSearchParams`. The outer layer is what keeps a `!` out of the + // entry request, which webpack would otherwise read as its + // inline-loader separator. + query: `?${new URLSearchParams({ + overlay: JSON.stringify({ + runtimeErrors: encodeURIComponent( + "function(error){return !/Injected/.test(error.message)}", + ), + }), + })}`, + code: boomApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await waitForRuntimeListeners(page); + + // Rejected by the filter, so nothing appears. + await page.evaluate(() => globalThis.boom("Injected error")); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + + // Accepted, so the filter is doing the deciding rather than suppressing + // everything — which a filter that failed to decode would also look like. + await page.evaluate(() => globalThis.boom("kept boom")); + const frame = await waitForOverlay(page); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "kept boom", + ); + }); + + it("keeps a runtime error thrown while the entry was still evaluating", async () => { + hotApp = await createHotApp({ + // Throws as the entry evaluates, which is before the transport's + // handshake completes. The build itself succeeded, so the catch-up sync + // that follows reports no problems at all. + // + // `subscribeAll` runs after the message has been fully processed, so it + // is the gate for "the sync has been handled" — waiting on a log line + // would not do, since a sync that changes nothing writes none. + code: ` + globalThis.handled = []; + require(${JSON.stringify(CLIENT_ENTRY)}).subscribeAll((obj) => { + globalThis.handled.push(obj.action); + }); + throw new Error("Injected error"); + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + const frame = await waitForOverlay(page); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "Injected error", + ); + + // The sync must not take the overlay down with it: a build that succeeded + // says nothing about an error the page threw on its own. This is + // webpack-dev-server#5024. + await page.waitForFunction( + () => globalThis.handled && globalThis.handled.includes("sync"), + { timeout: 30000 }, + ); + + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "Injected error", + ); + }); }); describe("overlay shared state across bundled copies (browser)", () => {