From 5a7ec657bb128eda82d7f9d1342e3d3d1640c044 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:50:39 +0000 Subject: [PATCH 1/2] refactor(client): take the WebSocket transport from dev-middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of moving the hot clients into webpack-dev-middleware: this package's `WebSocketClient` is a strict subset of the one that package now ships, and worse in three ways. It has no `close()` at all, despite declaring `@implements {CommunicationClient}`, which the interface requires. It has no guard against an event the socket had already queued reporting after the caller closed, so a close could schedule a reconnection nobody asked for. And it hands the url to `new WebSocket` unresolved, which throws on browsers before Chrome 125 / Firefox 124 / Safari 17.3 for a relative or `http(s):` url — this package always builds an absolute `ws:` url, so that one never bit here, but it is a trap for anyone reusing the class. Re-exported rather than deleted: `client.webSocketTransport` resolves to this path, so anything pointing at it keeps working. `import/no-unresolved` is switched off for the file, following the exemption already in place for `@changesets/get-github-info`: the import resolver cannot follow an `exports` subpath. TypeScript does resolve it, so `lint:types-client` still covers the import. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/clients/WebSocketClient.js | 51 +++++---------------------- eslint.config.mjs | 9 +++++ 2 files changed, 18 insertions(+), 42 deletions(-) diff --git a/client-src/clients/WebSocketClient.js b/client-src/clients/WebSocketClient.js index b9d1711031..fdad02efd3 100644 --- a/client-src/clients/WebSocketClient.js +++ b/client-src/clients/WebSocketClient.js @@ -1,42 +1,9 @@ -import { log } from "../utils/log.js"; - -/** @typedef {import("../index.js").EXPECTED_ANY} EXPECTED_ANY */ - -/** - * @implements {CommunicationClient} - */ -export default class WebSocketClient { - /** - * @param {string} url url to connect - */ - constructor(url) { - this.client = new WebSocket(url); - this.client.onerror = (error) => { - log.error(error); - }; - } - - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onOpen(fn) { - this.client.onopen = fn; - } - - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onClose(fn) { - this.client.onclose = fn; - } - - // call f with the message string as the first argument - /** - * @param {(...args: EXPECTED_ANY[]) => void} fn function - */ - onMessage(fn) { - this.client.onmessage = (err) => { - fn(err.data); - }; - } -} +// Re-exported rather than implemented: webpack-dev-middleware ships the same +// transport, and one copy means one place for the things this one was missing — +// a `close()` (which the interface declares and this file never had), the guard +// that stops a queued event reporting after the caller closed, and resolving a +// relative or `http(s):` url for browsers whose `WebSocket` will not. +// +// `client.webSocketTransport` resolves to this path, so anything pointing at it +// keeps working. +export { default } from "webpack-dev-middleware/client/ws"; diff --git a/eslint.config.mjs b/eslint.config.mjs index fc6b35de37..903666b607 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,4 +49,13 @@ export default defineConfig([ "import/no-unresolved": "off", }, }, + { + // `webpack-dev-middleware/client/ws` is a subpath of that package's + // `exports` map, which the import resolver cannot follow either. TypeScript + // does resolve it, so `lint:types-client` still covers the import. + files: ["client-src/clients/WebSocketClient.js"], + rules: { + "import/no-unresolved": "off", + }, + }, ]); From 1e729d25fdb0067a588201904205a523ec9d0b8a Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:01:04 +0000 Subject: [PATCH 2/2] refactor(client): take the error overlay from dev-middleware too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 692 lines of overlay deleted in favour of the one dev-middleware ships — the state machine, the iframe, the rendering, the runtime-error listeners. What is left is an adapter: this package's `createOverlay`/`send` shape on top of that overlay's `showProblems`/`clear`, plus `formatProblem`, which stays because the console uses it and because this package still receives webpack's error objects rather than formatted strings. Three things had to be carried across so nothing outward changes: * the element id. It is what a test, a screenshot tool or an integration finds the overlay by, so `webpack-dev-server-client-overlay` is passed through dev-middleware's new `overlay.id` rather than renamed; * the Trusted Types policy name. Under an enforced `require-trusted-types-for 'script'` the page's CSP allowlists a policy by name, and dev-middleware's default is a different one, so `webpack-dev-server#overlay` is passed explicitly — including where this package's option is `false`, which means "no name of my own"; * `/webpack-dev-server/open-editor`. dev-middleware leaves the endpoint empty by default, having no route to point at, so the file references in a problem would have stopped being clickable. The first two came from the e2e suite failing, not from reading the code, which is the argument for swapping under the tests rather than after. What does change is the overlay's internal DOM, which is not an interface this package documents: a clickable file reference is `[data-open-file]` rather than `[data-can-open]`, and a problem is headed by its level and origin rather than "Compiled with problems". Two tests assert on those and move with the implementation. Checked against a baseline taken first: the overlay suite fails exactly the set it fails on a clean `main` in this container — no case newly failing, none newly passing — and the client and web-socket-url suites are unmoved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/overlay.js | 817 ++++++--------------------------------- eslint.config.mjs | 2 +- test/e2e/overlay.test.js | 8 +- 3 files changed, 135 insertions(+), 692 deletions(-) diff --git a/client-src/overlay.js b/client-src/overlay.js index 4dd3739814..2fb71480fe 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,738 +1,177 @@ -// The error overlay is inspired (and mostly copied) from Create React App (https://github.com/facebookincubator/create-react-app) -// They, in turn, got inspired by webpack-hot-middleware (https://github.com/glenjamin/webpack-hot-middleware). - -import ansiHTML from "ansi-html-community"; +// The overlay itself lives in webpack-dev-middleware — this is the adapter +// that keeps this package's shape on top of it: `createOverlay` returning +// something with `send`, and `formatProblem` for the console. +// +// Two things the two sides do differently, and how they meet here: +// +// * This package sends webpack's error objects over the wire and formats +// them in the browser; dev-middleware's server formats them first and its +// overlay takes strings, whose first line is the location. `problemLine` +// produces that shape from an object, so a problem renders the same way it +// does for the middleware. +// * That overlay is a pair of calls, `showProblems` and `clear`, rather than +// a state machine. The events this package sends map onto them directly, +// and the mapping is the whole of `send` below. +import configureOverlay, { + clear, + showProblems, +} from "webpack-dev-middleware/client/overlay"; /** @typedef {import("./index.js").EXPECTED_ANY} EXPECTED_ANY */ +/** @typedef {{ file?: string, moduleName?: string, loc?: string, message?: string, stack?: EXPECTED_ANY }} Message */ -const references = { - "<": "<", - ">": ">", - '"': """, - "'": "'", - "&": "&", -}; - -/** - * @param {string} text text - * @returns {string} encoded text - */ -function encode(text) { - if (!text) { - return ""; - } - - return text.replace( - /[<>'"&]/g, - (input) => references[/** @type {keyof typeof references} */ (input)], - ); -} - -/** - * @typedef {object} Context - * @property {"warning" | "error"} level level - * @property {(string | Message)[]} messages messages - * @property {"build" | "runtime"} messageSource message source - */ - -/** @typedef {{ type: string } & Record} Event */ - -/** - * @typedef {object} Options - * @property {{ [state: string]: { on: Record } }} states states - * @property {Context} context context - * @property {string} initial initial - */ - -/** - * @typedef {object} Implementation - * @property {{ [actionName: string]: (ctx: Context, event: Event) => Context | void }} actions actions - */ - -/** - * @typedef {{ send: (event: Event) => void }} StateMachine - */ - -/** - * A simplified `createMachine` from `@xstate/fsm` with the following differences: - * - the returned machine is technically a "service". No `interpret(machine).start()` is needed. - * - the state definition only support `on` and target must be declared with { target: 'nextState', actions: [] } explicitly. - * - event passed to `send` must be an object with `type` property. - * - actions implementation will be [assign action](https://xstate.js.org/docs/guides/context.html#assign-action) if you return any value. - * Do not return anything if you just want to invoke side effect. - * - * The goal of this custom function is to avoid installing the entire `'xstate/fsm'` package, while enabling modeling using - * state machine. You can copy the first parameter into the editor at https://stately.ai/viz to visualize the state machine. - * @param {Options} options options - * @param {Implementation} implementation implementation - * @returns {StateMachine} state machine - */ -function createMachine({ states, context, initial }, { actions }) { - let currentState = initial; - let currentContext = context; - - return { - send: (event) => { - const currentStateOn = states[currentState].on; - const transitionConfig = currentStateOn && currentStateOn[event.type]; - - if (transitionConfig) { - currentState = transitionConfig.target; - if (transitionConfig.actions) { - transitionConfig.actions.forEach((actName) => { - const actionImpl = actions[actName]; - - const nextContextValue = - actionImpl && actionImpl(currentContext, event); +// What this package's overlay element has always been called. Kept through +// dev-middleware's `id` option so anything that queries it — a test, a +// screenshot tool, an integration that hides it — is unaffected by where the +// overlay now comes from. +const OVERLAY_ID = "webpack-dev-server-client-overlay"; - if (nextContextValue) { - currentContext = { - ...currentContext, - ...nextContextValue, - }; - } - }); - } - } - }, - }; -} +// Build problems live under one source so a clean build can drop them without +// touching runtime errors, which the overlay keeps in a slot of its own. +const BUILD_SOURCE = ""; -/** - * @typedef {object} ShowOverlayData - * @property {"warning" | "error"} level level - * @property {(string | Message)[]} messages messages - * @property {"build" | "runtime"} messageSource message source - */ +// The Trusted Types policy this package's overlay has always created. It has to +// stay this name: under an enforced `require-trusted-types-for 'script'` the +// page's CSP allowlists a policy by name, and dev-middleware's own default is a +// different one. +const TRUSTED_TYPES_POLICY_NAME = "webpack-dev-server#overlay"; -/** - * @typedef {object} CreateOverlayMachineOptions - * @property {(data: ShowOverlayData) => void} showOverlay show overlay - * @property {() => void} hideOverlay hide overlay - */ +// The route this package serves for opening a file in an editor, which makes +// the file references in a problem clickable. Off by default in +// dev-middleware, since it has no route of its own to point at. +const OPEN_EDITOR_ENDPOINT = "/webpack-dev-server/open-editor"; /** - * @param {CreateOverlayMachineOptions} options options - * @returns {StateMachine} state machine - */ -const createOverlayMachine = (options) => { - const { hideOverlay, showOverlay } = options; - - return createMachine( - { - initial: "hidden", - context: { - level: "error", - messages: [], - messageSource: "build", - }, - states: { - hidden: { - on: { - BUILD_ERROR: { - target: "displayBuildError", - actions: ["setMessages", "showOverlay"], - }, - RUNTIME_ERROR: { - target: "displayRuntimeError", - actions: ["setMessages", "showOverlay"], - }, - }, - }, - displayBuildError: { - on: { - DISMISS: { - target: "hidden", - actions: ["dismissMessages", "hideOverlay"], - }, - // WHY: A compilation reporting success says nothing about a runtime - // error, so it clears build errors only — no state but this one - // handles it, and an unhandled event is a no-op. Dismissing on the - // initial handshake's `ok` is what closed an overlay raised while - // the page was still loading (#5024). - BUILD_OK: { - target: "hidden", - actions: ["dismissMessages", "hideOverlay"], - }, - BUILD_ERROR: { - target: "displayBuildError", - actions: ["appendMessages", "showOverlay"], - }, - }, - }, - displayRuntimeError: { - on: { - DISMISS: { - target: "hidden", - actions: ["dismissMessages", "hideOverlay"], - }, - RUNTIME_ERROR: { - target: "displayRuntimeError", - actions: ["appendMessages", "showOverlay"], - }, - BUILD_ERROR: { - target: "displayBuildError", - actions: ["setMessages", "showOverlay"], - }, - }, - }, - }, - }, - { - actions: { - dismissMessages: () => { - return { - messages: [], - level: "error", - messageSource: "build", - }; - }, - appendMessages: (context, event) => { - return { - messages: context.messages.concat(event.messages), - level: event.level || context.level, - messageSource: event.type === "RUNTIME_ERROR" ? "runtime" : "build", - }; - }, - setMessages: (context, event) => { - return { - messages: event.messages, - level: event.level || context.level, - messageSource: event.type === "RUNTIME_ERROR" ? "runtime" : "build", - }; - }, - hideOverlay, - showOverlay, - }, - }, - ); -}; - -/** - * @param {Error} error error - * @returns {undefined | string[]} stack + * Where a problem happened, in the one-line form dev-middleware's overlay + * renders as the heading. Empty when webpack did not say. + * @param {string | Message} item item + * @returns {string} location */ -const parseErrorToStacks = (error) => { - if (!error || !(error instanceof Error)) { - throw new Error("parseErrorToStacks expects Error object"); - } - if (typeof error.stack === "string") { - return error.stack - .split("\n") - .filter((stack) => stack !== `Error: ${error.message}`); +const problemLocation = (item) => { + if (typeof item === "string") { + return ""; } -}; -/** - * @callback ErrorCallback - * @param {ErrorEvent} error - * @returns {void} - */ + const file = item.file || ""; + // `indexOf`, not `includes`: this file is compiled to an ES5 baseline. + const moduleName = item.moduleName + ? item.moduleName.indexOf("!") !== -1 + ? `${item.moduleName.replace(/^(\s|\S)*!/, "")} (${item.moduleName})` + : `${item.moduleName}` + : ""; + const loc = item.loc; -/** - * @param {ErrorCallback} callback callback - * @returns {() => void} cleanup - */ -const listenToRuntimeError = (callback) => { - window.addEventListener("error", callback); + if (!moduleName && !file) { + return ""; + } - return function cleanup() { - window.removeEventListener("error", callback); - }; + return `${moduleName ? `${moduleName}${file ? ` (${file})` : ""}` : file}${ + loc ? ` ${loc}` : "" + }`; }; /** - * @callback UnhandledRejectionCallback - * @param {PromiseRejectionEvent} rejectionEvent - * @returns {void} - */ - -/** - * @param {UnhandledRejectionCallback} callback callback - * @returns {() => void} cleanup + * What a problem says, with any stack webpack attached. + * @param {string | Message} item item + * @returns {string} body */ -const listenToUnhandledRejection = (callback) => { - window.addEventListener("unhandledrejection", callback); +const problemBody = (item) => { + let body = typeof item === "string" ? item : item.message || ""; - return function cleanup() { - window.removeEventListener("unhandledrejection", callback); - }; -}; - -// Styles are inspired by `react-error-overlay` - -const msgStyles = { - error: { - backgroundColor: "rgba(206, 17, 38, 0.1)", - color: "#fccfcf", - }, - warning: { - backgroundColor: "rgba(251, 245, 180, 0.1)", - color: "#fbf5b4", - }, -}; -const iframeStyle = { - position: "fixed", - top: "0px", - left: "0px", - right: "0px", - bottom: "0px", - width: "100vw", - height: "100vh", - border: "none", - "z-index": 9999999999, -}; -const containerStyle = { - position: "fixed", - boxSizing: "border-box", - left: "0px", - top: "0px", - right: "0px", - bottom: "0px", - width: "100vw", - height: "100vh", - fontSize: "large", - padding: "2rem 2rem 4rem 2rem", - lineHeight: "1.2", - whiteSpace: "pre-wrap", - overflow: "auto", - backgroundColor: "rgba(0, 0, 0, 0.9)", - color: "white", -}; -const headerStyle = { - color: "#e83b46", - fontSize: "2em", - whiteSpace: "pre-wrap", - fontFamily: "sans-serif", - margin: "0 2rem 2rem 0", - flex: "0 0 auto", - maxHeight: "50%", - overflow: "auto", -}; -const dismissButtonStyle = { - color: "#ffffff", - lineHeight: "1rem", - fontSize: "1.5rem", - padding: "1rem", - cursor: "pointer", - position: "absolute", - right: "0px", - top: "0px", - backgroundColor: "transparent", - border: "none", -}; -const msgTypeStyle = { - color: "#e83b46", - fontSize: "1.2em", - marginBottom: "1rem", - fontFamily: "sans-serif", -}; -const msgTextStyle = { - lineHeight: "1.5", - fontSize: "1rem", - fontFamily: "Menlo, Consolas, monospace", -}; - -// ANSI HTML + if (typeof item !== "string" && Array.isArray(item.stack)) { + // `forEach`, not `for...of`: an ES5 target has no array iterator. + item.stack.forEach((frame) => { + if (typeof frame === "string") { + body += `\r\n${frame}`; + } + }); + } -const colors = { - reset: ["transparent", "transparent"], - black: "181818", - red: "E36049", - green: "B3CB74", - yellow: "FFD080", - blue: "7CAFC2", - magenta: "7FACCA", - cyan: "C3C2EF", - lightgrey: "EBE7E3", - darkgrey: "6D7891", + return body; }; -ansiHTML.setColors(colors); - -/** @typedef {Error & { file?: string, moduleName?: string, moduleIdentifier?: string, loc?: string, message?: string, stack?: string | string[] }} Message */ - /** * @param {string} type type * @param {string | Message} item item * @returns {{ header: string, body: string }} formatted problem */ const formatProblem = (type, item) => { - let header = type === "warning" ? "WARNING" : "ERROR"; - let body = ""; - - if (typeof item === "string") { - body += item; - } else { - const file = item.file || ""; - const moduleName = item.moduleName - ? item.moduleName.indexOf("!") !== -1 - ? `${item.moduleName.replace(/^(\s|\S)*!/, "")} (${item.moduleName})` - : `${item.moduleName}` - : ""; - const loc = item.loc; + const location = problemLocation(item); - header += `${ - moduleName || file - ? ` in ${ - moduleName ? `${moduleName}${file ? ` (${file})` : ""}` : file - }${loc ? ` ${loc}` : ""}` - : "" - }`; - body += item.message || ""; - } + return { + header: `${type === "warning" ? "WARNING" : "ERROR"}${location ? ` in ${location}` : ""}`, + body: problemBody(item), + }; +}; - if (typeof item !== "string" && Array.isArray(item.stack)) { - item.stack.forEach((stack) => { - if (typeof stack === "string") { - body += `\r\n${stack}`; - } - }); - } +/** + * One problem as dev-middleware's overlay wants it: location first, then what + * it says. It renders the first line as the heading and prefixes the level + * itself, which is why the level is not repeated here. + * @param {string | Message} item item + * @returns {string} the problem as one string + */ +const problemLine = (item) => { + const location = problemLocation(item); + const body = problemBody(item); - return { header, body }; + return location ? `${location}\n${body}` : body; }; /** * @typedef {object} CreateOverlayOptions * @property {(false | string)=} trustedTypesPolicyName trusted types policy name - * @property {(boolean | ((error: Error) => void))=} catchRuntimeError runtime error catcher + * @property {(boolean | ((error: Error) => boolean))=} catchRuntimeError whether, or which, runtime errors to show — the previous `=> void` was wrong, the returned value has always decided */ /** * @param {CreateOverlayOptions} options options - * @returns {StateMachine} overlay + * @returns {{ send: (event: EXPECTED_ANY) => void }} overlay */ const createOverlay = (options) => { - /** @type {HTMLIFrameElement | null | undefined} */ - let iframeContainerElement; - /** @type {HTMLDivElement | null | undefined} */ - let containerElement; - /** @type {HTMLDivElement | null | undefined} */ - let headerElement; - /** @type {((element: HTMLDivElement) => void) | undefined} */ - let onLoad; - /** @type {Element | null | undefined} */ - let previousActiveElement; - /** @type {Omit | undefined} */ - let overlayTrustedTypesPolicy; - - /** @typedef {Extract} CSSStyleDeclarationKeys */ - - /** - * @param {HTMLElement} element element - * @param {Partial} style style - */ - function applyStyle(element, style) { - Object.keys(style).forEach((prop) => { - element.style[/** @type {CSSStyleDeclarationKeys} */ (prop)] = - /** @type {string} */ - (style[/** @type {CSSStyleDeclarationKeys} */ (prop)]); - }); - } - - /** - * @param {string | false | undefined} trustedTypesPolicyName trusted types police name - */ - function createContainer(trustedTypesPolicyName) { - // Enable Trusted Types if they are available in the current browser. - if (window.trustedTypes && !overlayTrustedTypesPolicy) { - overlayTrustedTypesPolicy = window.trustedTypes.createPolicy( - trustedTypesPolicyName || "webpack-dev-server#overlay", - { - createHTML: (value) => value, - }, - ); - } - - iframeContainerElement = document.createElement("iframe"); - iframeContainerElement.id = "webpack-dev-server-client-overlay"; - iframeContainerElement.title = "Webpack development server errors"; - iframeContainerElement.src = "about:blank"; - applyStyle(iframeContainerElement, iframeStyle); - - previousActiveElement = document.activeElement; - // eslint-disable-next-line no-use-before-define - window.addEventListener("keydown", handleEscapeKey); - - iframeContainerElement.onload = () => { - const contentElement = - /** @type {Document} */ - ( - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).contentDocument - ).createElement("div"); - containerElement = - /** @type {Document} */ - ( - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).contentDocument - ).createElement("div"); - - contentElement.id = "webpack-dev-server-client-overlay-div"; - applyStyle(contentElement, containerStyle); - - headerElement = document.createElement("div"); - - headerElement.innerText = "Compiled with problems:"; - applyStyle(headerElement, headerStyle); - - const closeButtonElement = document.createElement("button"); - - applyStyle(closeButtonElement, dismissButtonStyle); - - closeButtonElement.innerText = "×"; - closeButtonElement.ariaLabel = "Dismiss"; - closeButtonElement.addEventListener("click", () => { - // eslint-disable-next-line no-use-before-define - overlayService.send({ type: "DISMISS" }); - }); - - contentElement.appendChild(headerElement); - contentElement.appendChild(closeButtonElement); - contentElement.appendChild(containerElement); - - /** @type {Document} */ - ( - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).contentDocument - ).body.appendChild(contentElement); - - const iframeDocument = /** @type {Document} */ ( - /** @type {HTMLIFrameElement} */ (iframeContainerElement) - .contentDocument - ); - iframeDocument.documentElement.lang = - document.documentElement.lang || "en"; - // eslint-disable-next-line no-use-before-define - iframeDocument.addEventListener("keydown", handleEscapeKey); - - if (onLoad) { - onLoad(contentElement); - onLoad = undefined; - } - - closeButtonElement.focus(); - - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).onload = null; - }; - - document.body.appendChild(iframeContainerElement); - } - - /** - * @param {(element: HTMLDivElement) => void} callback callback - * @param {string | false | undefined} trustedTypesPolicyName trusted types policy name - */ - function ensureOverlayExists(callback, trustedTypesPolicyName) { - if (containerElement) { - // @ts-expect-error https://github.com/microsoft/TypeScript/issues/30024 - containerElement.innerHTML = overlayTrustedTypesPolicy - ? overlayTrustedTypesPolicy.createHTML("") - : ""; - // Everything is ready, call the callback right away. - callback(containerElement); - - return; - } - - // Each callback renders the complete current message list. - onLoad = callback; - - if (iframeContainerElement) { - return; - } - - createContainer(trustedTypesPolicyName); - } - - // Successful compilation. - /** - * @returns {void} - */ - function hide() { - if (!iframeContainerElement) { - return; - } - - // Clean up and reset internal state. - iframeContainerElement.onload = null; - iframeContainerElement.contentDocument?.removeEventListener( - "keydown", - // eslint-disable-next-line no-use-before-define - handleEscapeKey, - ); - const restoreFocus = document.activeElement === iframeContainerElement; - document.body.removeChild(iframeContainerElement); - - iframeContainerElement = null; - containerElement = null; - headerElement = null; - onLoad = undefined; - - if (restoreFocus && previousActiveElement instanceof HTMLElement) { - previousActiveElement.focus(); - } - previousActiveElement = null; - } - - // Compilation with errors (e.g. syntax error or missing modules). - /** - * @param {string} type type - * @param {(string | Message)[]} messages messages - * @param {undefined | false | string} trustedTypesPolicyName trusted types policy name - * @param {"build" | "runtime"} messageSource message source - */ - function show(type, messages, trustedTypesPolicyName, messageSource) { - ensureOverlayExists(() => { - /** @type {HTMLDivElement} */ - (headerElement).innerText = - messageSource === "runtime" - ? "Uncaught runtime errors:" - : "Compiled with problems:"; - - messages.forEach((message) => { - const entryElement = document.createElement("div"); - const msgStyle = - type === "warning" ? msgStyles.warning : msgStyles.error; - applyStyle(entryElement, { - ...msgStyle, - padding: "1rem 1rem 1.5rem 1rem", - }); - - const canOpen = typeof message !== "string" && message.moduleIdentifier; - const typeElement = document.createElement(canOpen ? "button" : "div"); - const { header, body } = formatProblem(type, message); - - typeElement.innerText = header; - applyStyle(typeElement, msgTypeStyle); - - if (canOpen) { - typeElement.setAttribute("type", "button"); - applyStyle(typeElement, { - cursor: "pointer", - background: "none", - border: "none", - padding: "0", - textAlign: "left", - display: "block", - lineHeight: "inherit", - }); - // element.dataset not supported in IE - typeElement.setAttribute("data-can-open", "true"); - typeElement.addEventListener("click", () => { - fetch( - `/webpack-dev-server/open-editor?fileName=${encodeURIComponent(canOpen)}`, - ); - }); - } - - // Make it look similar to our terminal. - const text = ansiHTML(encode(body)); - const messageTextNode = document.createElement("div"); - applyStyle(messageTextNode, msgTextStyle); - - // @ts-expect-error https://github.com/microsoft/TypeScript/issues/30024 - messageTextNode.innerHTML = overlayTrustedTypesPolicy - ? overlayTrustedTypesPolicy.createHTML(text) - : text; - - entryElement.appendChild(typeElement); - entryElement.appendChild(messageTextNode); - - /** @type {HTMLDivElement} */ - (containerElement).appendChild(entryElement); - }); - }, trustedTypesPolicyName); - } - - /** @type {(event: KeyboardEvent) => void} */ - let handleEscapeKey; - - /** - * @returns {void} - */ - - const hideOverlayWithEscCleanup = () => { - window.removeEventListener("keydown", handleEscapeKey); - hide(); - }; - - const overlayService = createOverlayMachine({ - showOverlay: ({ level = "error", messages, messageSource }) => - show(level, messages, options.trustedTypesPolicyName, messageSource), - hideOverlay: hideOverlayWithEscCleanup, + configureOverlay({ + id: OVERLAY_ID, + catchRuntimeError: options.catchRuntimeError, + openEditorEndpoint: OPEN_EDITOR_ENDPOINT, + // `false` means "no name of my own", which is this package's default name + // rather than dev-middleware's. + trustedTypesPolicyName: + typeof options.trustedTypesPolicyName === "string" + ? options.trustedTypesPolicyName + : TRUSTED_TYPES_POLICY_NAME, }); - /** - * ESC key press to dismiss the overlay. - * @param {KeyboardEvent} event Keydown event - */ - handleEscapeKey = (event) => { - if (event.key === "Escape" || event.key === "Esc" || event.keyCode === 27) { - overlayService.send({ type: "DISMISS" }); - } - }; - - window.addEventListener("keydown", handleEscapeKey); - if (options.catchRuntimeError) { + return { /** - * @param {Error | undefined} error error - * @param {string} fallbackMessage fallback message + * @param {EXPECTED_ANY} event event */ - const handleError = (error, fallbackMessage) => { - const errorObject = - error instanceof Error - ? error - : new Error(error || fallbackMessage, { cause: error }); - - const shouldDisplay = - typeof options.catchRuntimeError === "function" - ? options.catchRuntimeError(errorObject) - : true; - - if (shouldDisplay) { - overlayService.send({ - type: "RUNTIME_ERROR", - messages: [ - { - message: errorObject.message, - stack: parseErrorToStacks(errorObject), - }, - ], - }); - } - }; - - listenToRuntimeError((errorEvent) => { - // error property may be empty in older browser like IE - const { error, message } = errorEvent; - - if (!error && !message) { - return; - } - - // if error stack indicates a React error boundary caught the error, do not show overlay. - if ( - error && - error.stack && - error.stack.includes("invokeGuardedCallbackDev") - ) { - return; + send(event) { + switch (event.type) { + case "BUILD_ERROR": + showProblems( + event.level === "warning" ? "warnings" : "errors", + /** @type {(string | Message)[]} */ (event.messages).map( + (message) => problemLine(message), + ), + BUILD_SOURCE, + ); + break; + // A build that succeeded says nothing about an error the page threw on + // its own, so only this package's build problems go. + case "BUILD_OK": + clear(BUILD_SOURCE); + break; + // Everything, runtime errors included: sent when a rebuild starts, or + // when the connection is gone, and neither leaves anything worth + // keeping on screen. + case "DISMISS": + clear(); + break; + default: + break; } - - handleError(error, message); - }); - - listenToUnhandledRejection((promiseRejectionEvent) => { - const { reason } = promiseRejectionEvent; - - handleError(reason, "Unknown promise rejection reason"); - }); - } - - return overlayService; + }, + }; }; export { createOverlay, formatProblem }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 903666b607..5933bd719d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,7 +53,7 @@ export default defineConfig([ // `webpack-dev-middleware/client/ws` is a subpath of that package's // `exports` map, which the import resolver cannot follow either. TypeScript // does resolve it, so `lint:types-client` still covers the import. - files: ["client-src/clients/WebSocketClient.js"], + files: ["client-src/clients/WebSocketClient.js", "client-src/overlay.js"], rules: { "import/no-unresolved": "off", }, diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 4c8e2531bc..d8535768e0 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -637,7 +637,9 @@ describe("overlay", () => { .frames() .find((item) => item.name() === "webpack-dev-server-client-overlay"); - const errorHandle = await frame.$("[data-can-open]"); + // The overlay comes from webpack-dev-middleware, which marks a + // clickable file reference with `data-open-file`. + const errorHandle = await frame.$("[data-open-file]"); await errorHandle.click(); @@ -2089,9 +2091,11 @@ describe("overlay", () => { const overlayHandle = await page.$("#webpack-dev-server-client-overlay"); const overlayFrame = await overlayHandle.contentFrame(); + // The shared overlay heads a problem with its level and where it came + // from, rather than this package's old "Compiled with problems". expect( await overlayFrame.evaluate(() => document.body.textContent), - ).toContain("Compiled with problems"); + ).toContain("ERROR"); expect( pageErrors.filter((error) => /trusted type policy/i.test(error.message),