From d1dd4305bf3861c43caf374c101a80e65e18b138 Mon Sep 17 00:00:00 2001 From: Malcolm Kee Date: Sun, 7 May 2023 22:33:19 +1000 Subject: [PATCH] feat: overlay displays unhandled promise rejection (#4849) --- client-src/overlay.js | 33 ++++++-- client-src/overlay/runtime-error.js | 19 ++++- examples/client/overlay/README.md | 51 +++++++++++ examples/client/overlay/app.js | 38 ++++++++- examples/client/overlay/create-button.js | 16 ++++ examples/client/overlay/error-button.js | 24 ------ examples/client/overlay/webpack.config.js | 4 + .../overlay.test.js.snap.webpack4 | 84 +++++++++++++++++++ .../overlay.test.js.snap.webpack5 | 84 +++++++++++++++++++ test/e2e/overlay.test.js | 79 +++++++++++++++++ 10 files changed, 395 insertions(+), 37 deletions(-) create mode 100644 examples/client/overlay/create-button.js delete mode 100644 examples/client/overlay/error-button.js diff --git a/client-src/overlay.js b/client-src/overlay.js index 210b4996d1..5ac4ee90bf 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -5,6 +5,7 @@ import ansiHTML from "ansi-html-community"; import { encode } from "html-entities"; import { listenToRuntimeError, + listenToUnhandledRejection, parseErrorToStacks, } from "./overlay/runtime-error.js"; import createOverlayMachine from "./overlay/state-machine.js"; @@ -282,16 +283,13 @@ const createOverlay = (options) => { }); if (options.catchRuntimeError) { - listenToRuntimeError((errorEvent) => { - // error property may be empty in older browser like IE - const { error, message } = errorEvent; - - if (!error && !message) { - return; - } - + /** + * @param {Error | undefined} error + * @param {string} fallbackMessage + */ + const handleError = (error, fallbackMessage) => { const errorObject = - error instanceof Error ? error : new Error(error || message); + error instanceof Error ? error : new Error(error || fallbackMessage); const shouldDisplay = typeof options.catchRuntimeError === "function" @@ -309,6 +307,23 @@ const createOverlay = (options) => { ], }); } + }; + + listenToRuntimeError((errorEvent) => { + // error property may be empty in older browser like IE + const { error, message } = errorEvent; + + if (!error && !message) { + return; + } + + handleError(error, message); + }); + + listenToUnhandledRejection((promiseRejectionEvent) => { + const { reason } = promiseRejectionEvent; + + handleError(reason, "Unknown promise rejection reason"); }); } diff --git a/client-src/overlay/runtime-error.js b/client-src/overlay/runtime-error.js index a04fd82353..756afc845f 100644 --- a/client-src/overlay/runtime-error.js +++ b/client-src/overlay/runtime-error.js @@ -30,4 +30,21 @@ function listenToRuntimeError(callback) { }; } -export { listenToRuntimeError, parseErrorToStacks }; +/** + * @callback UnhandledRejectionCallback + * @param {PromiseRejectionEvent} rejectionEvent + * @returns {void} + */ + +/** + * @param {UnhandledRejectionCallback} callback + */ +function listenToUnhandledRejection(callback) { + window.addEventListener("unhandledrejection", callback); + + return function cleanup() { + window.removeEventListener("unhandledrejection", callback); + }; +} + +export { listenToRuntimeError, listenToUnhandledRejection, parseErrorToStacks }; diff --git a/examples/client/overlay/README.md b/examples/client/overlay/README.md index dd2b9ad870..12ad24762a 100644 --- a/examples/client/overlay/README.md +++ b/examples/client/overlay/README.md @@ -31,3 +31,54 @@ npx webpack serve --open --no-client-overlay 2. You should see an overlay in browser for compilation errors. 3. Update `entry` in webpack.config.js to `app.js` and save. 4. You should see the text on the page itself change to read `Success!`. + +## Additional Configurations + +### Filter errors by function + +**webpack.config.js** + +```js +module.exports = { + devServer: { + client: { + overlay: { + runtimeErrors: (msg) => { + if (msg) { + if (msg instanceof DOMException && msg.name === "AbortError") { + return false; + } + + let msgString; + + if (msg instanceof Error) { + msgString = msg.message; + } else if (typeof msg === "string") { + msgString = msg; + } + + if (msgString) { + return !/something/i.test(msgString); + } + } + + return true; + }, + }, + }, + }, +}; +``` + +Run the command: + +```shell +npx webpack serve --open +``` + +What should happens: + +1. When you click the "Click to throw error" button, the overlay should appears. +1. When you click the "Click to throw ignored error" button, the overlay should not appear but you should see an error is logged in console (default browser behavior). +1. When you click the "Click to throw unhandled promise rejection" button, the overlay should appears. +1. When you click the "Click to throw ignored promise rejection" button, the overlay should not appear but you should see an error is logged in console (default browser behavior). diff --git a/examples/client/overlay/app.js b/examples/client/overlay/app.js index 5885cfaf68..3741593aa7 100644 --- a/examples/client/overlay/app.js +++ b/examples/client/overlay/app.js @@ -1,18 +1,50 @@ "use strict"; // eslint-disable-next-line import/order -const createErrorBtn = require("./error-button"); +const createButton = require("./create-button"); + +/** + * @param {string} errorMessage + */ +function unsafeOperation(errorMessage) { + throw new Error(errorMessage); +} const target = document.querySelector("#target"); target.insertAdjacentElement( "afterend", - createErrorBtn("Click to throw error", "Error message thrown from JS") + createButton("Click to throw ignored promise rejection", () => { + const abortController = new AbortController(); + + fetch("https://google.com", { + signal: abortController.signal, + mode: "no-cors", + }); + + setTimeout(() => abortController.abort(), 100); + }) +); + +target.insertAdjacentElement( + "afterend", + createButton("Click to throw unhandled promise rejection", () => { + setTimeout(() => Promise.reject(new Error("Async error")), 100); + }) +); + +target.insertAdjacentElement( + "afterend", + createButton("Click to throw ignored error", () => { + unsafeOperation("something something"); + }) ); target.insertAdjacentElement( "afterend", - createErrorBtn("Click to throw ignored error", "something something") + createButton("Click to throw error", () => { + unsafeOperation("Error message thrown from JS"); + }) ); // eslint-disable-next-line import/no-unresolved, import/extensions diff --git a/examples/client/overlay/create-button.js b/examples/client/overlay/create-button.js new file mode 100644 index 0000000000..e37bdb62b7 --- /dev/null +++ b/examples/client/overlay/create-button.js @@ -0,0 +1,16 @@ +"use strict"; + +/** + * + * @param {string} label + * @param {() => void} onClick + * @returns HTMLButtonElement + */ +module.exports = function createButton(label, onClick) { + const button = document.createElement("button"); + + button.addEventListener("click", onClick); + button.innerHTML = label; + + return button; +}; diff --git a/examples/client/overlay/error-button.js b/examples/client/overlay/error-button.js deleted file mode 100644 index 2f0b87351e..0000000000 --- a/examples/client/overlay/error-button.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -/** - * - * @param {string} label - * @param {string} errorMessage - * @returns HTMLButtonElement - */ -module.exports = function createErrorButton(label, errorMessage) { - function unsafeOperation() { - throw new Error(errorMessage); - } - - function handleButtonClick() { - unsafeOperation(); - } - - const errorBtn = document.createElement("button"); - - errorBtn.addEventListener("click", handleButtonClick); - errorBtn.innerHTML = label; - - return errorBtn; -}; diff --git a/examples/client/overlay/webpack.config.js b/examples/client/overlay/webpack.config.js index a2413a2434..46d34de85f 100644 --- a/examples/client/overlay/webpack.config.js +++ b/examples/client/overlay/webpack.config.js @@ -14,6 +14,10 @@ module.exports = setup({ warnings: false, runtimeErrors: (msg) => { if (msg) { + if (msg instanceof DOMException && msg.name === "AbortError") { + return false; + } + let msgString; if (msg instanceof Error) { diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack4 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack4 index 44a1317cf9..f61db0a050 100644 --- a/test/e2e/__snapshots__/overlay.test.js.snap.webpack4 +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack4 @@ -2136,6 +2136,90 @@ exports[`overlay should show an error when "client.overlay.warnings" is "true": " `; +exports[`overlay should show error for uncaught promise rejection: overlay html 1`] = ` +" +
+
+ Uncaught runtime errors: +
+ +
+
+
+ ERROR +
+
+ Async error at <anonymous>:3:26 +
+
+
+
+ +" +`; + exports[`overlay should show error for uncaught runtime error: overlay html 1`] = ` "
+
+
+ Uncaught runtime errors: +
+ +
+
+
+ ERROR +
+
+ Async error at <anonymous>:3:26 +
+
+
+
+ +" +`; + exports[`overlay should show error for uncaught runtime error: overlay html 1`] = ` "
{ await browser.close(); await server.stop(); }); + + it("should show error for uncaught promise rejection", async () => { + const compiler = webpack(config); + + const server = new Server( + { + port, + }, + compiler + ); + + await server.start(); + + const { page, browser } = await runBrowser(); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle0", + }); + + await page.addScriptTag({ + content: `(function throwError() { + setTimeout(function () { + Promise.reject(new Error('Async error')); + }, 0); + })();`, + }); + + const overlayHandle = await page.$("#webpack-dev-server-client-overlay"); + const overlayFrame = await overlayHandle.contentFrame(); + const overlayHtml = await overlayFrame.evaluate( + () => document.body.outerHTML + ); + + expect(prettier.format(overlayHtml, { parser: "html" })).toMatchSnapshot( + "overlay html" + ); + + await browser.close(); + await server.stop(); + }); + + it("should not show filtered promise rejection", async () => { + const compiler = webpack(config); + + const server = new Server( + { + port, + client: { + overlay: { + runtimeErrors: (error) => !/Injected/.test(error.message), + }, + }, + }, + compiler + ); + + await server.start(); + + const { page, browser } = await runBrowser(); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle0", + }); + + await page.addScriptTag({ + content: `(function throwError() { + setTimeout(function () { + Promise.reject(new Error('Injected async error')); + }, 0); + })();`, + }); + + const overlayHandle = await page.$("#webpack-dev-server-client-overlay"); + + expect(overlayHandle).toBe(null); + + await browser.close(); + await server.stop(); + }); });