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 c934566d9..c17210d4a 100644 --- a/README.md +++ b/README.md @@ -375,19 +375,16 @@ 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\|Object` | `true` | In-page overlay for problems. Same value shape as webpack-dev-server's [`client.overlay`](https://webpack.js.org/configuration/dev-server/#overlay): a boolean, or a JSON object with `errors`, `warnings`, `runtimeErrors` (booleans or filter functions) and `trustedTypesPolicyName`. Partial objects are filled with `true`. Also accepts the webpack-dev-middleware extensions `styles` (CSS overrides for the overlay card), `ansiColors` (ANSI → HTML color map) and `openEditorEndpoint` (when set, file references become clickable and issue `GET ?fileName=`; the endpoint is provided by your server, e.g. a route calling [launch-editor](https://github.com/yyx990803/launch-editor)). | +| `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 @@ -426,6 +423,24 @@ hotClient.setOptionsAndConnect({ path: "/__hmr" }); hotClient.disconnect(); ``` +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, + // openEditorEndpoint +}); + +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/globals.d.ts b/client-src/globals.d.ts index 3c4550a3f..81281c637 100644 --- a/client-src/globals.d.ts +++ b/client-src/globals.d.ts @@ -14,7 +14,7 @@ interface ClientReporter { type: "errors" | "warnings", obj: { errors: string[]; warnings: string[]; name?: string }, ): boolean; - success(): void; + success(obj?: { name?: string }): void; useCustomOverlay(customOverlay: unknown): void; } @@ -23,7 +23,17 @@ interface EventSourceWrapper { close(): void; } +interface OverlayTrustedTypesPolicy { + createHTML(value: string): string; +} + interface Window { __wdmEventSourceWrapper?: Record; __webpack_dev_middleware_hot_reporter__?: ClientReporter; + trustedTypes?: { + createPolicy( + name: string, + rules: { createHTML(value: string): string }, + ): OverlayTrustedTypesPolicy; + }; } diff --git a/client-src/index.js b/client-src/index.js index ece002e3c..acb4b354e 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -7,18 +7,28 @@ import stripAnsi from "./utils/strip-ansi.js"; /** @typedef {import("./utils/log.js").LogLevel} LogLevel */ +/** + * Superset of webpack-dev-server's `client.overlay` object; `styles`, + * `ansiColors` and `openEditorEndpoint` are webpack-dev-middleware extensions. + * @typedef {object} OverlayOptions + * @property {(boolean | ((error: string) => boolean))=} errors show build errors in the overlay + * @property {(boolean | ((warning: string) => boolean))=} warnings show build warnings in the overlay + * @property {(boolean | ((error: Error) => boolean))=} runtimeErrors show uncaught runtime errors and unhandled rejections in the overlay + * @property {string=} trustedTypesPolicyName Trusted Types policy name used for the overlay's HTML + * @property {Record=} styles overrides for the overlay card CSS + * @property {Record=} ansiColors overrides for ANSI → HTML color mapping + * @property {string=} openEditorEndpoint endpoint the overlay calls (GET `?fileName=file:line:column`) when a file reference is clicked; empty disables it + */ + /** * @typedef {object} ClientOptions * @property {string} path SSE endpoint path * @property {number} timeout reconnection timeout in milliseconds - * @property {boolean} overlay enable the in-page error overlay + * @property {boolean | OverlayOptions} overlay enable the in-page error overlay (same value shape as webpack-dev-server's `client.overlay`) * @property {boolean} reload reload the page when HMR cannot apply the update * @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 {boolean} overlayWarnings show warnings in the overlay too - * @property {Record} ansiColors overrides for ANSI → HTML color mapping */ /** @type {ClientOptions} */ @@ -30,11 +40,35 @@ const options = { logging: "info", name: "", autoConnect: true, - overlayStyles: {}, - overlayWarnings: false, - ansiColors: {}, }; +/** + * Turn the string values that `errors`/`warnings`/`runtimeErrors` may carry + * in the resource query into filter functions (same behavior as + * webpack-dev-server). + * @param {boolean | OverlayOptions} overlayOptions overlay options + */ +function decodeOverlayOptions(overlayOptions) { + if (typeof overlayOptions === "object") { + for (const property of ["errors", "warnings", "runtimeErrors"]) { + const value = + overlayOptions[/** @type {keyof OverlayOptions} */ (property)]; + + if (typeof value === "string") { + const filterFunctionString = decodeURIComponent(value); + + /** @type {EXPECTED_ANY} */ (overlayOptions)[property] = + // eslint-disable-next-line no-new-func + new Function( + "message", + `var callback = ${filterFunctionString} + return callback(message)`, + ); + } + } + } +} + setLogLevel(options.logging); /** @@ -46,7 +80,28 @@ function setOverrides(overrides) { } if (overrides.path) options.path = overrides.path; if (overrides.timeout) options.timeout = Number(overrides.timeout); - if (overrides.overlay) options.overlay = overrides.overlay !== "false"; + if (overrides.overlay) { + // Same value shape as webpack-dev-server's `client.overlay`: a boolean or + // a JSON object with `errors`, `warnings`, `runtimeErrors` (booleans or + // encoded filter functions) and `trustedTypesPolicyName`. + try { + options.overlay = JSON.parse(overrides.overlay); + } catch { + options.overlay = overrides.overlay !== "false"; + } + + // Fill in default "true" params for partially-specified objects. + if (typeof options.overlay === "object") { + options.overlay = { + errors: true, + warnings: true, + runtimeErrors: true, + ...options.overlay, + }; + + decodeOverlayOptions(options.overlay); + } + } if (overrides.reload) options.reload = overrides.reload !== "false"; if (overrides.logging) { options.logging = /** @type {LogLevel} */ (overrides.logging); @@ -59,17 +114,6 @@ function setOverrides(overrides) { options.path = __webpack_public_path__ + options.path; } - if (overrides.ansiColors) { - options.ansiColors = JSON.parse(overrides.ansiColors); - } - if (overrides.overlayStyles) { - options.overlayStyles = JSON.parse(overrides.overlayStyles); - } - - if (overrides.overlayWarnings) { - options.overlayWarnings = overrides.overlayWarnings === "true"; - } - setLogLevel(options.logging); } @@ -211,7 +255,7 @@ export function disconnect() { * @returns {{ * cleanProblemsCache: () => void, * problems: (type: "errors" | "warnings", obj: HMRPayload) => boolean, - * success: () => void, + * success: (obj?: HMRPayload) => void, * useCustomOverlay: (customOverlay: EXPECTED_ANY) => void, * }} reporter */ @@ -219,25 +263,102 @@ function createReporter() { /** @type {EXPECTED_ANY} */ let overlay; if (typeof document !== "undefined" && options.overlay) { - overlay = configureOverlay({ - ansiColors: options.ansiColors, - overlayStyles: options.overlayStyles, - }); + // Same mapping as webpack-dev-server's createOverlay call, extended with + // the webpack-dev-middleware-specific keys. + overlay = configureOverlay( + typeof options.overlay === "object" + ? { + catchRuntimeError: options.overlay.runtimeErrors, + trustedTypesPolicyName: options.overlay.trustedTypesPolicyName, + ansiColors: options.overlay.ansiColors, + overlayStyles: options.overlay.styles, + openEditorEndpoint: options.overlay.openEditorEndpoint, + } + : { + catchRuntimeError: options.overlay, + }, + ); } - /** @type {string | null} */ - let previousProblems = null; + // Console de-duplication cache, keyed per bundle name and type so interleaved + // multi-compiler payloads do not defeat it. + /** @type {Map} */ + const previousProblems = new Map(); + + // Live problems per compilation name. A multi-compiler publishes one event + // per bundle; a success from one bundle must not wipe another bundle's + // still-valid errors from the overlay. + /** @type {Map} */ + const problemsByName = new Map(); + + /** + * Resolve the show/hide/filter setting for a problem type. Same resolution + * as webpack-dev-server: a boolean overlay applies to both types; an object + * carries a boolean or a filter function per type. + * @param {"errors" | "warnings"} type problem type + * @param {string[]} problems problems of one bundle + * @returns {string[]} the problems the overlay should show + */ + const filterForOverlay = (type, problems) => { + const setting = + typeof options.overlay === "boolean" + ? options.overlay + : options.overlay && options.overlay[type]; + + if (!setting) { + return []; + } + + return typeof setting === "function" + ? problems.filter((message) => setting(message)) + : problems; + }; + + /** + * Render the union of every bundle's live problems, or clear the overlay + * when nothing is left. + * @returns {boolean} true when nothing is shown + */ + const renderOverlay = () => { + if (!overlay) { + return true; + } + + /** @type {string[]} */ + const errors = []; + /** @type {string[]} */ + const warnings = []; + + for (const entry of problemsByName.values()) { + errors.push(...filterForOverlay("errors", entry.errors)); + warnings.push(...filterForOverlay("warnings", entry.warnings)); + } + + if (errors.length > 0) { + overlay.showProblems("errors", errors); + return false; + } + + if (warnings.length > 0) { + overlay.showProblems("warnings", warnings); + return false; + } + + overlay.clear(); + return true; + }; /** * @param {"errors" | "warnings"} type problem type * @param {HMRPayload} obj payload */ const logProblems = (type, obj) => { + const cacheKey = `${obj.name || ""}|${type}`; const newProblems = obj[type].map(stripAnsi).join("\n"); - if (previousProblems === newProblems) { + if (previousProblems.get(cacheKey) === newProblems) { return; } - previousProblems = newProblems; + previousProblems.set(cacheKey, newProblems); const name = obj.name ? `'${obj.name}' ` : ""; const title = `bundle ${name}has ${obj[type].length} ${type}`; @@ -252,21 +373,19 @@ function createReporter() { return { cleanProblemsCache() { - previousProblems = null; + previousProblems.clear(); }, problems(type, obj) { logProblems(type, obj); - if (overlay) { - if (options.overlayWarnings || type === "errors") { - overlay.showProblems(type, obj[type]); - return false; - } - overlay.clear(); - } - return true; + problemsByName.set(obj.name || "", { + errors: obj.errors || [], + warnings: obj.warnings || [], + }); + return renderOverlay(); }, - success() { - if (overlay) overlay.clear(); + success(obj) { + problemsByName.delete((obj && obj.name) || ""); + renderOverlay(); }, useCustomOverlay(customOverlay) { overlay = customOverlay; @@ -313,12 +432,14 @@ function processMessage(obj) { if (reporter) reporter.problems("errors", obj); shouldApply = false; } else if (obj.warnings.length > 0) { + // Warnings are reported (and possibly shown in the overlay) but do + // not block the update, matching webpack-dev-server. if (reporter) { - shouldApply = reporter.problems("warnings", obj); + reporter.problems("warnings", obj); } } else if (reporter) { reporter.cleanProblemsCache(); - reporter.success(); + reporter.success(obj); } if (shouldApply) { applyUpdate(obj.hash, options); diff --git a/client-src/overlay.js b/client-src/overlay.js index 7e8065cad..6d6a553fa 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,68 @@ 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; +/** @type {boolean | ((error: Error) => boolean)} */ +let catchRuntimeError = false; + +// When set, the file chips in error messages become clickable and issue +// `GET ?fileName=`. The endpoint itself is +// provided by the server integration (e.g. a route that calls launch-editor, +// like webpack-dev-server's `/webpack-dev-server/open-editor`). +/** @type {string} */ +let openEditorEndpoint = ""; + +// 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 @@ -161,6 +245,18 @@ function highlightFilePath(html) { ); } + if (openEditorEndpoint) { + const position = location.trim().replace(/^:/, ""); + + return ( + '' + + `${filePath}${location}\n` + ); + } + return `${filePath}${location}\n`; }, ); @@ -184,53 +280,240 @@ 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(); + } + }); + + // Open the clicked file reference through the configured endpoint. + frameDocument.addEventListener("click", (event) => { + const target = /** @type {EXPECTED_ANY} */ (event.target); + const opener = + target && typeof target.closest === "function" + ? target.closest("[data-open-file]") + : null; + + if (opener && openEditorEndpoint) { + fetch( + `${openEditorEndpoint}?fileName=${encodeURIComponent( + opener.getAttribute("data-open-file"), + )}`, + ); + } + }); + + // 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 fallback message */ -export function clear() { - if (clientOverlay.parentNode) { - clientOverlay.remove(); +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; } + + const errorObject = + error instanceof Error ? error : new Error(error || fallbackMessage); + + // `catchRuntimeError` may be a filter function, like in webpack-dev-server. + const shouldDisplay = + typeof catchRuntimeError === "function" + ? catchRuntimeError(errorObject) + : true; + + if (!shouldDisplay) { + return; + } + + const stack = errorObject.stack ? `\n${errorObject.stack}` : ""; + + runtimeMessages.push( + `Uncaught runtime error: ${errorObject.message}${stack}`, + ); + showProblems("errors", runtimeMessages); } /** - * @param {{ ansiColors?: Record, overlayStyles?: Record }} options options + * 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, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ export default function configureOverlay(options) { + if (options.trustedTypesPolicyName) { + trustedTypesPolicyName = options.trustedTypesPolicyName; + } + + if (options.openEditorEndpoint !== undefined) { + openEditorEndpoint = options.openEditorEndpoint; + } + + if (options.catchRuntimeError !== undefined) { + catchRuntimeError = options.catchRuntimeError; + } + + if (catchRuntimeError) { + attachRuntimeErrorListeners(); + } + if (options.ansiColors) { for (const color of Object.keys(options.ansiColors)) { if (color in colors) { @@ -246,14 +529,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 +538,3 @@ export default function configureOverlay(options) { clear, }; } - -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ diff --git a/examples/hot-multi-compiler/README.md b/examples/hot-multi-compiler/README.md new file mode 100644 index 000000000..2cea2fb38 --- /dev/null +++ b/examples/hot-multi-compiler/README.md @@ -0,0 +1,51 @@ +# Hot module replacement with a MultiCompiler + +Same setup as the [hot example](../hot), but with **two compilers** ("app" and +"admin") sharing one middleware instance. Both bundles run on the same page +and share a single SSE connection; each carries the client runtime with +`?name=` so it only applies updates for its own compiler. + +## What it shows + +- One `{ hot: true }` middleware serving events for every compiler. +- Scoping each bundle's client with `?name=` in a MultiCompiler. +- Per-compilation error tracking in the overlay: one bundle's clean build + does not hide another bundle's still-valid errors. +- Why each configuration needs a distinct `output.uniqueName`: with both + bundles on one page, a shared `webpackHotUpdate…` global would make each + bundle's hot updates land in the wrong runtime. + +## Files + +| File | Purpose | +| --------------------- | --------------------------------------------------------------- | +| `server.js` | Express server mounting the middleware with `hot: true`. | +| `webpack.config.js` | MultiCompiler ("app" + "admin") with per-bundle client entries. | +| `src/index.js` | "app" entry that accepts updates via `module.hot`. | +| `src/render.js` | Edit to rebuild only the "app" bundle. | +| `src/admin.js` | "admin" entry, hot-updated independently from "app". | +| `src/admin-render.js` | Edit to rebuild only the "admin" bundle. | +| `public/index.html` | Demo page that loads both bundles. | + +## Running + +From the repository root, build the package first so `dist/` and `client/` +exist: + +```bash +npm run build +node examples/hot-multi-compiler/server.js +``` + +Then open . + +## Things to try + +- Edit `src/render.js` — only "app" rebuilds and hot-updates; "admin" just + publishes a `sync` event. +- Break `src/render.js` (e.g. leave a dangling `const x =`) so the overlay + shows the "app" errors, then save `src/admin-render.js` — the successful + "admin" build does **not** clear the overlay: the reporter tracks problems + per compilation, so "app"'s errors stay visible until "app" itself builds + clean. +- Break both files to see the union of their errors in one overlay. diff --git a/examples/hot-multi-compiler/public/index.html b/examples/hot-multi-compiler/public/index.html new file mode 100644 index 000000000..b70aa098a --- /dev/null +++ b/examples/hot-multi-compiler/public/index.html @@ -0,0 +1,16 @@ + + + + + + webpack-dev-middleware — hot multi-compiler example + + +
+ + + + + + diff --git a/examples/hot-multi-compiler/server.js b/examples/hot-multi-compiler/server.js new file mode 100644 index 000000000..a59e08d3f --- /dev/null +++ b/examples/hot-multi-compiler/server.js @@ -0,0 +1,25 @@ +const path = require("path"); +const express = require("express"); +const webpack = require("webpack"); +const middleware = require("webpack-dev-middleware"); +const config = require("./webpack.config.js"); + +const compiler = webpack(config); +const app = express(); + +// `hot: true` serves a Server-Sent Events endpoint (at `/__webpack_hmr` by +// default) that the browser runtime subscribes to. The bundled files are still +// served from memory at `output.publicPath`. +app.use(middleware(compiler, { hot: true })); + +// Serve the demo page for any non-asset request. +app.get("/", (req, res) => { + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +const port = process.env.PORT || 3000; + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Example app listening on http://localhost:${port}`); +}); diff --git a/examples/hot-multi-compiler/src/admin-render.js b/examples/hot-multi-compiler/src/admin-render.js new file mode 100644 index 000000000..f6a852a49 --- /dev/null +++ b/examples/hot-multi-compiler/src/admin-render.js @@ -0,0 +1,7 @@ +export function renderAdmin() { + const root = document.getElementById("admin-root"); + + // Edit this string and save — only the "admin" bundle rebuilds; the "app" + // bundle publishes a `sync` event and stays untouched. + root.textContent = "Hello from the admin bundle!"; +} diff --git a/examples/hot-multi-compiler/src/admin.js b/examples/hot-multi-compiler/src/admin.js new file mode 100644 index 000000000..9496d1da4 --- /dev/null +++ b/examples/hot-multi-compiler/src/admin.js @@ -0,0 +1,11 @@ +import { renderAdmin } from "./admin-render.js"; + +renderAdmin(); + +// Accept updates to `admin-render.js` and re-run it so the DOM reflects the +// change without reloading the page. +if (module.hot) { + module.hot.accept("./admin-render.js", () => { + renderAdmin(); + }); +} diff --git a/examples/hot-multi-compiler/src/index.js b/examples/hot-multi-compiler/src/index.js new file mode 100644 index 000000000..e0d0aa2a1 --- /dev/null +++ b/examples/hot-multi-compiler/src/index.js @@ -0,0 +1,11 @@ +import { render } from "./render.js"; + +render(); + +// Accept updates to `render.js` and re-run it so the DOM reflects the change +// without reloading the page. +if (module.hot) { + module.hot.accept("./render.js", () => { + render(); + }); +} diff --git a/examples/hot-multi-compiler/src/render.js b/examples/hot-multi-compiler/src/render.js new file mode 100644 index 000000000..865fb7e78 --- /dev/null +++ b/examples/hot-multi-compiler/src/render.js @@ -0,0 +1,7 @@ +export function render() { + const root = document.getElementById("root"); + + // Edit this string and save — only the "app" bundle rebuilds; the "admin" + // bundle publishes a `sync` event and stays untouched. + root.textContent = "Hello from the app bundle!"; +} diff --git a/examples/hot-multi-compiler/webpack.config.js b/examples/hot-multi-compiler/webpack.config.js new file mode 100644 index 000000000..363fc921b --- /dev/null +++ b/examples/hot-multi-compiler/webpack.config.js @@ -0,0 +1,40 @@ +const path = require("path"); +const webpack = require("webpack"); + +// The package `exports` field does not allow query strings on subpaths, so +// resolve the client entry to a file path first and append the query to that. +const client = require.resolve("webpack-dev-middleware/client"); + +/** + * Two compilers ("app" and "admin") sharing one middleware instance. Each + * bundle carries the client runtime with `?name=` so it only + * applies updates for its own compiler — both share a single SSE connection. + * @param {string} name compilation name + * @param {string} entry app entry + * @returns {import("webpack").Configuration} configuration + */ +function makeConfig(name, entry) { + return { + name, + mode: "development", + context: __dirname, + entry: [`${client}?name=${name}`, entry], + output: { + path: path.resolve(__dirname, "dist"), + publicPath: "/", + filename: `${name}.js`, + // Both bundles run on the same page: without a distinct uniqueName they + // would share the same global `webpackHotUpdate…` callback and each + // other's hot updates would land in the wrong runtime. + uniqueName: name, + hotUpdateChunkFilename: `${name}.[id].[fullhash].hot-update.js`, + hotUpdateMainFilename: `${name}.[runtime].[fullhash].hot-update.json`, + }, + plugins: [new webpack.HotModuleReplacementPlugin()], + }; +} + +module.exports = [ + makeConfig("app", "./src/index.js"), + makeConfig("admin", "./src/admin.js"), +]; diff --git a/examples/hot/README.md b/examples/hot/README.md index 4602cd629..e713be61b 100644 --- a/examples/hot/README.md +++ b/examples/hot/README.md @@ -14,13 +14,13 @@ over [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Serve ## Files -| File | Purpose | -| ------------------- | --------------------------------------------------------------- | -| `server.js` | Express server mounting the middleware with `hot: true`. | -| `webpack.config.js` | Adds the client runtime entry and `HotModuleReplacementPlugin`. | -| `src/index.js` | App entry that accepts updates via `module.hot`. | -| `src/render.js` | The module you edit to see HMR in action. | -| `public/index.html` | Demo page that loads the bundle. | +| File | Purpose | +| ------------------- | -------------------------------------------------------------------------- | +| `server.js` | Express server mounting the middleware with `hot: true`. | +| `webpack.config.js` | Adds the client runtime entry and `HotModuleReplacementPlugin`. | +| `src/index.js` | App entry that accepts updates via `module.hot`. | +| `src/render.js` | The module you edit to see HMR in action (also has runtime-error buttons). | +| `public/index.html` | Demo page that loads the bundle. | ## Running @@ -36,6 +36,20 @@ node examples/hot/server.js Then open and edit `examples/hot/src/render.js`. The page updates in place — no reload. +## Trying the overlay + +- **Multiple build errors:** break `src/render.js` (e.g. leave a dangling + `const x =`) and save — the overlay lists every error of the failing build. +- **Runtime errors:** click "Throw runtime error" / "Unhandled rejection" + several times — the overlay accumulates them until dismissed (or until the + next successful rebuild clears it). +- **Warnings:** start the server with `DEMO_WARNING=1` — every build + emits a sample warning, shown in the overlay by default. Hide warnings with + `?overlay={"warnings":false}` on the client entry in `webpack.config.js`. +- **Multi-compiler:** see the dedicated + [hot-multi-compiler example](../hot-multi-compiler) for two bundles on one + page with per-compilation error tracking. + Open your browser's console to see the client runtime log the HMR lifecycle (`[webpack-dev-middleware] connected`, `App is up to date.`, …). Server-side logs (`Client connected`, build status) are printed through webpack's diff --git a/examples/hot/src/render.js b/examples/hot/src/render.js index e4d3812bf..cdbf8760a 100644 --- a/examples/hot/src/render.js +++ b/examples/hot/src/render.js @@ -3,6 +3,19 @@ export function render() { // Edit this string (or anything below) and save — the page updates in place, // without a full reload, thanks to hot module replacement. - root.textContent = - "Hello from webpack-dev-middleware hot module replacement!"; + root.innerHTML = ` +

Hello from webpack-dev-middleware hot module replacement!

+ + + `; + + // Each click adds one more uncaught error — the overlay accumulates them + // until it is dismissed or the next successful rebuild clears it. + root.querySelector("#throw-error").addEventListener("click", () => { + throw new Error(`Demo runtime error #${Date.now() % 1000}`); + }); + + root.querySelector("#reject-promise").addEventListener("click", () => { + Promise.reject(new Error("Demo unhandled rejection")); + }); } diff --git a/examples/hot/webpack.config.js b/examples/hot/webpack.config.js index 84827bc84..a0c6621d4 100644 --- a/examples/hot/webpack.config.js +++ b/examples/hot/webpack.config.js @@ -1,6 +1,19 @@ const path = require("path"); const webpack = require("webpack"); +// Opt-in build warning so the overlay's warning display can be tried out: +// run the server with `DEMO_WARNING=1`. Warnings are shown in the overlay by +// default; hide them with `?overlay={"warnings":false}` on the client entry. +const demoWarningPlugin = { + apply(compiler) { + compiler.hooks.thisCompilation.tap("DemoWarning", (compilation) => { + compilation.warnings.push( + new Error("Demo: this is a sample build warning"), + ); + }); + }, +}; + module.exports = { mode: "development", // `webpack-dev-middleware/client` is the small runtime that subscribes to the @@ -13,5 +26,8 @@ module.exports = { publicPath: "/", filename: "main.js", }, - plugins: [new webpack.HotModuleReplacementPlugin()], + plugins: [ + new webpack.HotModuleReplacementPlugin(), + ...(process.env.DEMO_WARNING ? [demoWarningPlugin] : []), + ], }; diff --git a/package-lock.json b/package-lock.json index f3bcd0f91..74389ce9b 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", @@ -11215,6 +11214,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 300d1d50e..4501982dd 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/__snapshots__/client.test.js.snap.webpack5 b/test/__snapshots__/client.test.js.snap.webpack5 index e03872bc6..dfb887fca 100644 --- a/test/__snapshots__/client.test.js.snap.webpack5 +++ b/test/__snapshots__/client.test.js.snap.webpack5 @@ -1,24 +1,24 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`client with default options does not show overlay on warning builds by default 1`] = ` +exports[`client with default options shows overlay on errored builds 1`] = ` [ [ - "[webpack-dev-middleware] bundle has 1 warnings", + "[webpack-dev-middleware] bundle has 2 errors", ], [ - "[webpack-dev-middleware] This isn't great, but it's not terrible", + "[webpack-dev-middleware] Something broke +Actually, 2 things broke", ], ] `; -exports[`client with default options shows overlay on errored builds 1`] = ` +exports[`client with default options shows overlay on warning builds by default (dev-server parity) 1`] = ` [ [ - "[webpack-dev-middleware] bundle has 2 errors", + "[webpack-dev-middleware] bundle has 1 warnings", ], [ - "[webpack-dev-middleware] Something broke -Actually, 2 things broke", + "[webpack-dev-middleware] This isn't great, but it's not terrible", ], ] `; @@ -53,7 +53,7 @@ exports[`client with logging option logging=warn silences info but keeps warn 1` ] `; -exports[`client with overlayWarnings: true shows overlay on errored builds 1`] = ` +exports[`client with overlay warnings enabled via the dev-server-shaped option shows overlay on errored builds 1`] = ` [ [ "[webpack-dev-middleware] bundle has 2 errors", diff --git a/test/client.test.js b/test/client.test.js index c190b6d37..763d62436 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -269,7 +269,7 @@ describe("client", () => { expect(clientOverlay.clear).toHaveBeenCalledTimes(1); }); - it("hides overlay after errored build becomes a warning", () => { + it("updates overlay when an errored build becomes a warning", () => { const es = EventSourceStub.lastInstance(); es.onmessage( makeMessage({ @@ -291,8 +291,12 @@ describe("client", () => { modules: [], }), ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.clear).toHaveBeenCalledTimes(1); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); + expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("warnings", [ + "This isn't great, but it's not terrible", + ]); + // The overlay content is replaced, not dismissed. + expect(clientOverlay.clear).not.toHaveBeenCalled(); }); it("triggers webpack on warning builds", () => { @@ -309,7 +313,7 @@ describe("client", () => { expect(processUpdate).toHaveBeenCalledTimes(1); }); - it("does not show overlay on warning builds by default", () => { + it("shows overlay on warning builds by default (dev-server parity)", () => { EventSourceStub.lastInstance().onmessage( makeMessage({ action: "built", @@ -320,8 +324,10 @@ describe("client", () => { modules: [], }), ); - expect(clientOverlay.showProblems).not.toHaveBeenCalled(); - // Warnings still surface through the logger even when the overlay stays hidden. + expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ + "This isn't great, but it's not terrible", + ]); + // Warnings also surface through the logger. expect(console.warn.mock.calls).toMatchSnapshot(); }); @@ -347,11 +353,15 @@ describe("client", () => { modules: [], }), ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); + expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); + expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ + "Something broke", + "Actually, 2 things broke", + ]); }); }); - describe("with overlayWarnings: true", () => { + describe("with multi-compiler payloads", () => { let EventSourceStub; beforeEach(() => { @@ -361,7 +371,134 @@ describe("client", () => { jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); jest.spyOn(console, "error").mockImplementation(() => {}); - loadClient("?overlayWarnings=true"); + loadClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps one bundle's errors when another bundle succeeds", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + name: "app", + time: 100, + hash: "app-hash", + errors: ["app broke"], + warnings: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ + "app broke", + ]); + + // A clean build from another bundle must not wipe app's errors. + es.onmessage( + makeMessage({ + action: "built", + name: "admin", + time: 100, + hash: "admin-hash", + errors: [], + warnings: [], + }), + ); + expect(clientOverlay.clear).not.toHaveBeenCalled(); + expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ + "app broke", + ]); + + // Fixing the broken bundle finally clears the overlay. + es.onmessage( + makeMessage({ + action: "built", + name: "app", + time: 100, + hash: "app-hash-2", + errors: [], + warnings: [], + }), + ); + expect(clientOverlay.clear).toHaveBeenCalledTimes(1); + }); + + it("shows the union of problems from every broken bundle", () => { + const es = EventSourceStub.lastInstance(); + es.onmessage( + makeMessage({ + action: "built", + name: "app", + time: 100, + hash: "app-hash", + errors: ["app broke"], + warnings: [], + }), + ); + es.onmessage( + makeMessage({ + action: "built", + name: "admin", + time: 100, + hash: "admin-hash", + errors: ["admin broke too"], + warnings: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ + "app broke", + "admin broke too", + ]); + }); + }); + + describe("with an overlay warnings filter function", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + loadClient( + '?overlay={"warnings":"function(message){return message.includes(`keep`)}"}', + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("only shows the warnings the filter keeps (dev-server parity)", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ + action: "built", + time: 100, + hash: "1234567890abcdef", + errors: [], + warnings: ["drop this warning", "keep this warning"], + modules: [], + }), + ); + expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ + "keep this warning", + ]); + }); + }); + + describe("with overlay warnings enabled via the dev-server-shaped option", () => { + let EventSourceStub; + + beforeEach(() => { + EventSourceStub = makeEventSourceStub(); + globalThis.EventSource = EventSourceStub; + jest.spyOn(console, "info").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + loadClient('?overlay={"warnings":true}'); }); afterEach(() => { @@ -545,6 +682,26 @@ describe("client", () => { }); }); + describe("with overlay runtime/trusted-types options", () => { + it("forwards them to the overlay factory", () => { + globalThis.EventSource = makeEventSourceStub(); + + loadClient( + '?overlay={"runtimeErrors":false,"trustedTypesPolicyName":"webpack#overlay","openEditorEndpoint":"/__open-editor"}', + ); + + const overlayFactory = require("../client-src/overlay"); + + expect(overlayFactory).toHaveBeenCalledWith( + expect.objectContaining({ + catchRuntimeError: false, + trustedTypesPolicyName: "webpack#overlay", + openEditorEndpoint: "/__open-editor", + }), + ); + }); + }); + describe("connection lifecycle", () => { let EventSourceStub; let client; @@ -684,7 +841,7 @@ describe("client", () => { }); it("logging=warn silences info but keeps warn", () => { - loadClient("?logging=warn&overlayWarnings=true"); + loadClient("?logging=warn"); EventSourceStub.lastInstance().onmessage( makeMessage({ action: "built", diff --git a/test/overlay.test.js b/test/overlay.test.js index 70f866e4d..fded3880e 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,116 @@ 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("open in editor", () => { + afterEach(() => { + configureOverlay({ openEditorEndpoint: "" }); + delete globalThis.fetch; + }); + + it("makes file chips clickable and calls the configured endpoint", () => { + // eslint-disable-next-line jest/prefer-spy-on -- jsdom does not define fetch + globalThis.fetch = jest.fn(() => Promise.resolve()); + configureOverlay({ openEditorEndpoint: "/__open-editor" }); + showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); + + const chip = /** @type {Document} */ ( + getOverlay().contentDocument + ).querySelector("[data-open-file]"); + + expect(chip).not.toBeNull(); + expect(chip.getAttribute("data-open-file")).toBe("./src/render.js:7:2"); + + chip.click(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + `/__open-editor?fileName=${encodeURIComponent("./src/render.js:7:2")}`, + ); + }); + + it("does not mark file chips when no endpoint is configured", () => { + showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); + + expect( + /** @type {Document} */ (getOverlay().contentDocument).querySelector( + "[data-open-file]", + ), + ).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({});