From 9b8c38784db8ea317f20c431d36fb36a94342f4e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 14 Jul 2026 00:05:47 -0500 Subject: [PATCH] feat(client): bring the error overlay to parity with webpack-dev-server - Render inside an about:blank iframe and style exclusively through the CSSOM so the overlay works under a strict style-src CSP; inline styles from ansi-html are re-applied via style.cssText. - Support Trusted Types: innerHTML writes go through a policy (configurable via overlayTrustedTypesPolicyName). - Capture uncaught runtime errors and unhandled rejections in the overlay (overlayRuntimeErrors, default true), with the same React error boundary heuristic as dev-server. - Inline the HTML entity encoder and drop the html-entities dependency. - Expose the overlay as a standalone subpath export (webpack-dev-middleware/client/overlay) so webpack-dev-server can reuse it. Ref webpack/webpack-hot-middleware#457 --- .cspell.json | 1 + README.md | 45 +++-- client-src/index.js | 17 +- client-src/overlay.js | 378 +++++++++++++++++++++++++++++++++--------- package-lock.json | 2 +- package.json | 2 +- test/client.test.js | 19 +++ test/overlay.test.js | 90 +++++++++- 8 files changed, 456 insertions(+), 98 deletions(-) diff --git a/.cspell.json b/.cspell.json index ecd7d2e23..f6a1adbc2 100644 --- a/.cspell.json +++ b/.cspell.json @@ -24,6 +24,7 @@ "finalhandler", "hono", "rspack", + "apos", "malformed" ], "ignorePaths": [ diff --git a/README.md b/README.md index 676495526..feae5219a 100644 --- a/README.md +++ b/README.md @@ -375,19 +375,21 @@ entry: [ ### Client options -| Name | Type | Default | Description | -| :-----------------: | :-------: | :--------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------- | -| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | -| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | -| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. | -| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. | -| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. | -| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. | -| `reload` | `boolean` | `true` | Fall back to a full page reload when an update cannot be applied through HMR (e.g. recovering from a broken build). Set to `false` to keep HMR-only. | -| `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. | -| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). | -| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. | -| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. | +| Name | Type | Default | Description | +| :-----------------------------: | :-------: | :--------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | `string` | `/__webpack_hmr` | Path the SSE endpoint is served at. Must match the server `hot.path`. | +| `timeout` | `number` | `20000` | Reconnection / heartbeat watchdog timeout in milliseconds. | +| `overlay` | `boolean` | `true` | Show compile-time errors in an in-page overlay. | +| `overlayWarnings` | `boolean` | `false` | Also show compile-time warnings in the overlay. | +| `overlayRuntimeErrors` | `boolean` | `true` | Also show uncaught runtime errors and unhandled promise rejections in the overlay. | +| `overlayTrustedTypesPolicyName` | `string` | `undefined` | Trusted Types policy name used for the overlay's HTML (for pages served with `require-trusted-types-for 'script'`). | +| `overlayStyles` | `Object` | `{}` | JSON object of CSS overrides for the overlay container. Pass JSON-encoded value via query string. | +| `ansiColors` | `Object` | `{}` | JSON object overriding the ANSI → HTML color map used by the overlay. | +| `reload` | `boolean` | `true` | Fall back to a full page reload when an update cannot be applied through HMR (e.g. recovering from a broken build). Set to `false` to keep HMR-only. | +| `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. | +| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). | +| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. | +| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. | ### Programmatic API @@ -422,6 +424,23 @@ hotClient.useCustomOverlay({ hotClient.setOptionsAndConnect({ path: "/__hmr" }); ``` +The error overlay is also exposed as a standalone module so other tooling +(e.g. `webpack-dev-server`) can reuse it without the SSE client: + +```js +import configureOverlay, { + clear, + showProblems, +} from "webpack-dev-middleware/client/overlay"; + +const overlay = configureOverlay({ + // ansiColors, overlayStyles, trustedTypesPolicyName, catchRuntimeError +}); + +overlay.showProblems("errors", ["Something broke"]); +overlay.clear(); +``` + ## API `webpack-dev-middleware` also provides convenience methods that can be use to diff --git a/client-src/index.js b/client-src/index.js index 106ab1f8e..ee75de192 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -17,8 +17,10 @@ import { log, setLogLevel } from "./utils/log.js"; * @property {LogLevel} logging logger level * @property {string} name limit updates to this compilation name * @property {boolean} autoConnect connect immediately when the entry runs - * @property {Record} overlayStyles overrides for the overlay container CSS + * @property {Record} overlayStyles overrides for the overlay card CSS * @property {boolean} overlayWarnings show warnings in the overlay too + * @property {boolean} overlayRuntimeErrors show uncaught runtime errors and unhandled rejections in the overlay + * @property {string} overlayTrustedTypesPolicyName Trusted Types policy name used for the overlay's HTML * @property {Record} ansiColors overrides for ANSI → HTML color mapping */ @@ -33,6 +35,8 @@ const options = { autoConnect: true, overlayStyles: {}, overlayWarnings: false, + overlayRuntimeErrors: true, + overlayTrustedTypesPolicyName: "", ansiColors: {}, }; @@ -71,6 +75,15 @@ function setOverrides(overrides) { options.overlayWarnings = overrides.overlayWarnings === "true"; } + if (overrides.overlayRuntimeErrors) { + options.overlayRuntimeErrors = overrides.overlayRuntimeErrors !== "false"; + } + + if (overrides.overlayTrustedTypesPolicyName) { + options.overlayTrustedTypesPolicyName = + overrides.overlayTrustedTypesPolicyName; + } + setLogLevel(options.logging); } @@ -200,6 +213,8 @@ function createReporter() { overlay = configureOverlay({ ansiColors: options.ansiColors, overlayStyles: options.overlayStyles, + catchRuntimeError: options.overlayRuntimeErrors, + trustedTypesPolicyName: options.overlayTrustedTypesPolicyName, }); } diff --git a/client-src/overlay.js b/client-src/overlay.js index 7e8065cad..2d034a772 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,63 +1,58 @@ import ansiHTML from "ansi-html-community"; -import { encode as encodeHtmlEntity } from "html-entities"; - -// The backdrop dims the page and centers the error card. -const clientOverlay = document.createElement("div"); -clientOverlay.id = "webpack-dev-middleware-hot-overlay"; - -// The card is the visible panel that holds the problem messages. -const overlayCard = document.createElement("div"); -clientOverlay.append(overlayCard); - -// A close (×) button pinned to the top-right corner of the card. -const closeButton = document.createElement("button"); -closeButton.type = "button"; -closeButton.textContent = "×"; -closeButton.setAttribute("aria-label", "Close"); -closeButton.style.position = "absolute"; -closeButton.style.top = "8px"; -closeButton.style.right = "12px"; -closeButton.style.border = "none"; -closeButton.style.background = "transparent"; -closeButton.style.color = "#999999"; -closeButton.style.fontSize = "22px"; -closeButton.style.lineHeight = "1"; -closeButton.style.cursor = "pointer"; -closeButton.style.padding = "0"; -closeButton.addEventListener("click", () => { - clear(); -}); -// Dismiss the overlay when clicking the backdrop (but not the card itself). -clientOverlay.addEventListener("click", (event) => { - if (event.target === clientOverlay) { - clear(); - } -}); +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ -// Dismiss the overlay when pressing Escape. -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - clear(); +/** @type {Record} */ +const characterReferences = { + "<": "<", + ">": ">", + '"': """, + "'": "'", + "&": "&", +}; + +/** + * Encode the characters that are meaningful in HTML. Inlined (same as + * webpack-dev-server's overlay) so the client does not need `html-entities`. + * @param {string} text raw text + * @returns {string} entity-encoded text + */ +function encodeHtmlEntity(text) { + if (!text) { + return ""; } -}); -/** @type {Record} */ + return text.replace(/[<>'"&]/g, (character) => { + return characterReferences[character]; + }); +} + +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; +const CARD_ID = `${OVERLAY_ID}-card`; + +// The overlay lives inside an `about:blank` iframe (same pattern as +// webpack-dev-server) so page styles cannot leak into it and its styles cannot +// leak out. Every style is applied through the CSSOM (`element.style`), which +// a strict `style-src` Content Security Policy allows, unlike inline `style` +// attributes. + +/** + * The iframe acts as the backdrop: it covers the viewport and dims the page. + * @type {Record} + */ const backdropStyles = { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, + width: "100vw", + height: "100vh", + border: "none", zIndex: 9999, // webpack "Outer Space" (#2B3A42), translucent. background: "rgba(43,58,66,0.72)", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "32px", - boxSizing: "border-box", - overflow: "auto", }; /** @type {Record} */ @@ -84,6 +79,33 @@ const styles = { textAlign: "left", }; +/** @type {Record} */ +const bodyStyles = { + margin: 0, + padding: "32px", + boxSizing: "border-box", + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "auto", + background: "transparent", +}; + +/** @type {Record} */ +const closeButtonStyles = { + position: "absolute", + top: "8px", + right: "12px", + border: "none", + background: "transparent", + color: "#999999", + fontSize: "22px", + lineHeight: "1", + cursor: "pointer", + padding: "0", +}; + /** @type {Record} */ const colors = { reset: ["transparent", "transparent"], @@ -98,6 +120,59 @@ const colors = { darkgrey: "6d7891", }; +/** @type {HTMLIFrameElement | null} */ +let overlayFrame = null; +/** @type {HTMLElement | null} */ +let overlayCard = null; + +// Runtime error capture (same behavior as webpack-dev-server's +// `catchRuntimeError`): uncaught errors and unhandled promise rejections are +// rendered in the overlay. Messages accumulate until the overlay is cleared. +/** @type {string[]} */ +let runtimeMessages = []; +let runtimeListenersAttached = false; + +// Trusted Types support (same pattern as webpack-dev-server): when the page +// runs under `require-trusted-types-for 'script'`, every `innerHTML` write +// must go through a policy. +/** @type {{ createHTML: (value: string) => EXPECTED_ANY } | undefined} */ +let trustedTypesPolicy; +/** @type {string | undefined} */ +let trustedTypesPolicyName; + +/** + * @param {HTMLElement} element element + * @param {string} html html to assign + */ +function setHTML(element, html) { + element.innerHTML = trustedTypesPolicy + ? trustedTypesPolicy.createHTML(html) + : html; +} + +/** + * @param {EXPECTED_ANY} element element + * @param {Record} style style map + */ +function applyStyle(element, style) { + for (const key of Object.keys(style)) { + element.style[key] = style[key]; + } +} + +/** + * Re-apply the inline `style` attributes produced by `ansi-html` (and our own + * highlight helpers) through the CSSOM. Under a strict `style-src` CSP the + * parser ignores `style` attributes, but CSSOM writes are always allowed. + * @param {HTMLElement} root subtree to normalize + */ +function normalizeInlineStyles(root) { + for (const element of root.querySelectorAll("[style]")) { + /** @type {EXPECTED_ANY} */ (element).style.cssText = + element.getAttribute("style"); + } +} + /** * @param {"errors" | "warnings"} type problem type * @returns {string | string[]} hex color (without `#`) for the given type @@ -184,53 +259,211 @@ function linkify(html) { }); } +// Dismiss the overlay when pressing Escape while the page has focus. +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + clear(); + } +}); + +/** + * Create (or return) the overlay iframe and the card inside it. + * @returns {HTMLElement | null} the card element, or null when the frame + * document is not available + */ +function ensureOverlay() { + if (overlayFrame && overlayCard && overlayFrame.parentNode) { + return overlayCard; + } + + // Enable Trusted Types if they are available in the current browser. + if (window.trustedTypes && !trustedTypesPolicy) { + trustedTypesPolicy = window.trustedTypes.createPolicy( + trustedTypesPolicyName || "webpack-dev-middleware#overlay", + { + createHTML: (value) => value, + }, + ); + } + + overlayFrame = document.createElement("iframe"); + overlayFrame.id = OVERLAY_ID; + overlayFrame.src = "about:blank"; + applyStyle(overlayFrame, backdropStyles); + document.body.append(overlayFrame); + + // A same-origin `about:blank` document is available synchronously. + const frameDocument = overlayFrame.contentDocument; + + if (!frameDocument || !frameDocument.body) { + overlayFrame.remove(); + overlayFrame = null; + + return null; + } + + applyStyle(frameDocument.body, bodyStyles); + + // Dismiss the overlay when pressing Escape while the frame has focus. + frameDocument.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + clear(); + } + }); + + // Dismiss the overlay when clicking the backdrop (but not the card itself). + frameDocument.addEventListener("click", (event) => { + if ( + overlayCard && + !overlayCard.contains(/** @type {EXPECTED_ANY} */ (event.target)) + ) { + clear(); + } + }); + + // The card is the visible panel that holds the problem messages. + overlayCard = frameDocument.createElement("div"); + overlayCard.id = CARD_ID; + applyStyle(overlayCard, styles); + frameDocument.body.append(overlayCard); + + return overlayCard; +} + /** * @param {"errors" | "warnings"} type problem type * @param {string[]} lines messages to render */ export function showProblems(type, lines) { + const card = ensureOverlay(); + + if (!card) { + return; + } + + const frameDocument = /** @type {Document} */ ( + /** @type {HTMLIFrameElement} */ (overlayFrame).contentDocument + ); + // Accent the top bar with the problem color (red for errors, yellow for warnings). - overlayCard.style.borderTopColor = `#${problemColor(type)}`; - overlayCard.innerHTML = ""; - overlayCard.append(closeButton); + card.style.borderTopColor = `#${problemColor(type)}`; + setHTML(card, ""); + + // A close (×) button pinned to the top-right corner of the card. + const closeButton = frameDocument.createElement("button"); + closeButton.type = "button"; + closeButton.textContent = "×"; + closeButton.setAttribute("aria-label", "Close"); + applyStyle(closeButton, closeButtonStyles); + closeButton.addEventListener("click", () => { + clear(); + }); + card.append(closeButton); + for (const line of lines) { const msg = linkify( highlightFilePath(highlightCodeFrame(ansiHTML(encodeHtmlEntity(line)))), ); - const div = document.createElement("div"); + const div = frameDocument.createElement("div"); div.style.marginBottom = "20px"; - div.innerHTML = `${problemType(type)} in ${msg}`; - overlayCard.append(div); + setHTML(div, `${problemType(type)} in ${msg}`); + normalizeInlineStyles(div); + card.append(div); } - const hint = document.createElement("div"); - hint.style.marginTop = "4px"; - hint.style.paddingTop = "16px"; - hint.style.borderTop = "1px solid #465e69"; - hint.style.color = "#999999"; - hint.style.fontSize = "13px"; + const hint = frameDocument.createElement("div"); + applyStyle(hint, { + marginTop: "4px", + paddingTop: "16px", + borderTop: "1px solid #465e69", + color: "#999999", + fontSize: "13px", + }); hint.textContent = "Click outside, press Esc, or fix the code to dismiss."; - overlayCard.append(hint); + card.append(hint); +} - if (document.body) { - document.body.append(clientOverlay); +/** + * Remove the overlay iframe from the DOM. + */ +export function clear() { + if (overlayFrame && overlayFrame.parentNode) { + overlayFrame.remove(); } + + overlayFrame = null; + overlayCard = null; + runtimeMessages = []; } /** - * Remove the overlay container from the DOM. + * @param {EXPECTED_ANY} error thrown value + * @param {string} fallbackMessage message used when the thrown value is not an Error + * @returns {string} printable message with stack */ -export function clear() { - if (clientOverlay.parentNode) { - clientOverlay.remove(); +function formatRuntimeError(error, fallbackMessage) { + const errorObject = + error instanceof Error ? error : new Error(error || fallbackMessage); + const stack = errorObject.stack ? `\n${errorObject.stack}` : ""; + + return `Uncaught runtime error: ${errorObject.message}${stack}`; +} + +/** + * @param {EXPECTED_ANY} error thrown value + * @param {string} fallbackMessage fallback message + */ +function handleRuntimeError(error, fallbackMessage) { + // If the error stack indicates a React error boundary caught the error, do + // not show the overlay (same heuristic as webpack-dev-server). + if ( + error && + error.stack && + error.stack.includes("invokeGuardedCallbackDev") + ) { + return; + } + + runtimeMessages.push(formatRuntimeError(error, fallbackMessage)); + showProblems("errors", runtimeMessages); +} + +/** + * Listen for uncaught errors and unhandled rejections on the page. + */ +function attachRuntimeErrorListeners() { + if (runtimeListenersAttached) { + return; } + + runtimeListenersAttached = true; + + window.addEventListener("error", (event) => { + if (!event.error && !event.message) { + return; + } + + handleRuntimeError(event.error, event.message); + }); + + window.addEventListener("unhandledrejection", (event) => { + handleRuntimeError(event.reason, "Unknown promise rejection reason"); + }); } /** - * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options + * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ export default function configureOverlay(options) { + if (options.trustedTypesPolicyName) { + trustedTypesPolicyName = options.trustedTypesPolicyName; + } + + if (options.catchRuntimeError) { + attachRuntimeErrorListeners(); + } + if (options.ansiColors) { for (const color of Object.keys(options.ansiColors)) { if (color in colors) { @@ -246,14 +479,8 @@ export default function configureOverlay(options) { } } - for (const key of Object.keys(backdropStyles)) { - /** @type {EXPECTED_ANY} */ - (clientOverlay.style)[key] = backdropStyles[key]; - } - - for (const key of Object.keys(styles)) { - /** @type {EXPECTED_ANY} */ - (overlayCard.style)[key] = styles[key]; + if (overlayCard) { + applyStyle(overlayCard, styles); } return { @@ -261,6 +488,3 @@ export default function configureOverlay(options) { clear, }; } - -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ diff --git a/package-lock.json b/package-lock.json index 3a4ce53a4..dddd5c828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "ansi-html-community": "^0.0.8", - "html-entities": "^2.6.0", "memfs": "^4.56.10", "mime-types": "^3.0.2", "on-finished": "^2.4.1", @@ -11216,6 +11215,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index ab9d21e10..fb2cab367 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "default": "./dist/index.js" }, "./client": "./client/index.js", + "./client/overlay": "./client/overlay.js", "./package.json": "./package.json" }, "main": "dist/index.js", @@ -58,7 +59,6 @@ }, "dependencies": { "ansi-html-community": "^0.0.8", - "html-entities": "^2.6.0", "memfs": "^4.56.10", "mime-types": "^3.0.2", "on-finished": "^2.4.1", diff --git a/test/client.test.js b/test/client.test.js index e310b30b0..fce4a9d45 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -536,6 +536,25 @@ describe("client", () => { }); }); + describe("with overlay runtime/trusted-types options", () => { + it("forwards them to the overlay factory", () => { + globalThis.EventSource = makeEventSourceStub(); + + loadClient( + "?overlayRuntimeErrors=false&overlayTrustedTypesPolicyName=webpack%23overlay", + ); + + const overlayFactory = require("../client-src/overlay"); + + expect(overlayFactory).toHaveBeenCalledWith( + expect.objectContaining({ + catchRuntimeError: false, + trustedTypesPolicyName: "webpack#overlay", + }), + ); + }); + }); + describe("connection lifecycle", () => { let EventSourceStub; let client; diff --git a/test/overlay.test.js b/test/overlay.test.js index 70f866e4d..614881102 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -4,21 +4,28 @@ import configureOverlay, { clear, showProblems } from "../client-src/overlay"; +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; /** - * @returns {HTMLElement | null} the overlay backdrop, if mounted + * @returns {HTMLIFrameElement | null} the overlay iframe (the backdrop), if mounted */ function getOverlay() { - return document.getElementById(OVERLAY_ID); + return /** @type {HTMLIFrameElement | null} */ ( + document.getElementById(OVERLAY_ID) + ); } /** - * @returns {HTMLElement} the visible card element inside the backdrop + * @returns {HTMLElement} the visible card element inside the iframe */ function getCard() { return /** @type {HTMLElement} */ ( - /** @type {HTMLElement} */ (getOverlay()).firstElementChild + /** @type {Document} */ ( + /** @type {HTMLIFrameElement} */ (getOverlay()).contentDocument + ).getElementById(`${OVERLAY_ID}-card`) ); } @@ -136,7 +143,7 @@ describe("overlay", () => { it("closes when clicking the backdrop", () => { showProblems("errors", ["boom"]); - getOverlay().click(); + getOverlay().contentDocument.body.click(); expect(getOverlay()).toBeNull(); }); @@ -155,6 +162,79 @@ describe("overlay", () => { }); }); + describe("runtime errors", () => { + it("shows uncaught errors in the overlay and accumulates them", () => { + configureOverlay({ catchRuntimeError: true }); + + globalThis.dispatchEvent( + new ErrorEvent("error", { + error: new Error("boom-runtime"), + message: "boom-runtime", + }), + ); + + expect(getOverlay()).not.toBeNull(); + expect(getCard().textContent).toContain( + "Uncaught runtime error: boom-runtime", + ); + + globalThis.dispatchEvent( + new ErrorEvent("error", { + error: new Error("boom-2"), + message: "boom-2", + }), + ); + + expect(getCard().textContent).toContain("boom-runtime"); + expect(getCard().textContent).toContain("boom-2"); + }); + + it("shows unhandled promise rejections", () => { + configureOverlay({ catchRuntimeError: true }); + + const event = new Event("unhandledrejection"); + /** @type {EXPECTED_ANY} */ (event).reason = new Error("rejected-boom"); + globalThis.dispatchEvent(event); + + expect(getOverlay()).not.toBeNull(); + expect(getCard().textContent).toContain("rejected-boom"); + }); + + it("ignores errors already caught by a React error boundary", () => { + configureOverlay({ catchRuntimeError: true }); + + const error = new Error("boundary"); + error.stack = + "Error: boundary\n at invokeGuardedCallbackDev (react-dom.js:1:1)"; + globalThis.dispatchEvent(new ErrorEvent("error", { error })); + + expect(getOverlay()).toBeNull(); + }); + }); + + describe("trusted types", () => { + afterEach(() => { + delete globalThis.trustedTypes; + }); + + it("creates a policy with the configured name and renders through it", () => { + globalThis.trustedTypes = { + createPolicy: jest.fn((name, rules) => ({ + createHTML: rules.createHTML, + })), + }; + + configureOverlay({ trustedTypesPolicyName: "custom#policy" }); + showProblems("errors", ["boom"]); + + expect(globalThis.trustedTypes.createPolicy).toHaveBeenCalledWith( + "custom#policy", + expect.objectContaining({ createHTML: expect.any(Function) }), + ); + expect(getCard().textContent).toContain("boom"); + }); + }); + describe("configureOverlay", () => { it("returns the overlay API", () => { const api = configureOverlay({});