diff --git a/client-src/index.js b/client-src/index.js index 7ea51c0c63..706c77cfe1 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -4,7 +4,7 @@ import webpackHotLog from "webpack/hot/log.js"; import stripAnsi from "./utils/stripAnsi.js"; import parseURL from "./utils/parseURL.js"; import socket from "./socket.js"; -import { formatProblem, show, hide } from "./overlay.js"; +import { formatProblem, createOverlay } from "./overlay.js"; import { log, logEnabledFeatures, setLogLevel } from "./utils/log.js"; import sendMessage from "./utils/sendMessage.js"; import reloadApp from "./utils/reloadApp.js"; @@ -115,6 +115,13 @@ self.addEventListener("beforeunload", () => { status.isUnloading = true; }); +const trustedTypesPolicyName = + typeof options.overlay === "object" && options.overlay.trustedTypesPolicyName; + +const overlay = createOverlay({ + trustedTypesPolicyName, +}); + const onSocketMessage = { hot() { if (parsedResourceQuery.hot === "false") { @@ -135,7 +142,7 @@ const onSocketMessage = { // Fixes #1042. overlay doesn't clear if errors are fixed but warnings remain. if (options.overlay) { - hide(); + overlay.send("DISMISS"); } sendMessage("Invalid"); @@ -192,7 +199,7 @@ const onSocketMessage = { log.info("Nothing changed."); if (options.overlay) { - hide(); + overlay.send("DISMISS"); } sendMessage("StillOk"); @@ -201,7 +208,7 @@ const onSocketMessage = { sendMessage("Ok"); if (options.overlay) { - hide(); + overlay.send("DISMISS"); } reloadApp(options, status); @@ -256,10 +263,11 @@ const onSocketMessage = { : options.overlay && options.overlay.warnings; if (needShowOverlayForWarnings) { - const trustedTypesPolicyName = - typeof options.overlay === "object" && - options.overlay.trustedTypesPolicyName; - show("warning", warnings, trustedTypesPolicyName || null); + overlay.send({ + type: "BUILD_ERROR", + level: "warning", + messages: warnings, + }); } if (params && params.preventReloading) { @@ -292,10 +300,11 @@ const onSocketMessage = { : options.overlay && options.overlay.errors; if (needShowOverlayForErrors) { - const trustedTypesPolicyName = - typeof options.overlay === "object" && - options.overlay.trustedTypesPolicyName; - show("error", errors, trustedTypesPolicyName || null); + overlay.send({ + type: "BUILD_ERROR", + level: "error", + messages: errors, + }); } }, /** @@ -308,7 +317,7 @@ const onSocketMessage = { log.info("Disconnected!"); if (options.overlay) { - hide(); + overlay.send("DISMISS"); } sendMessage("Close"); diff --git a/client-src/overlay.js b/client-src/overlay.js index 4e7909b370..7e2ac75e96 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -3,6 +3,7 @@ import ansiHTML from "ansi-html-community"; import { encode } from "html-entities"; +import { interpret } from "@xstate/fsm"; import { containerStyle, dismissButtonStyle, @@ -12,6 +13,8 @@ import { msgTextStyle, msgTypeStyle, } from "./overlay/styles.js"; +import { listenToRuntimeError } from "./overlay/runtime-error.js"; +import createOverlayMachine from "./overlay/state-machine.js"; const colors = { reset: ["transparent", "transparent"], @@ -26,127 +29,8 @@ const colors = { darkgrey: "6D7891", }; -/** @type {HTMLIFrameElement | null | undefined} */ -let iframeContainerElement; -/** @type {HTMLDivElement | null | undefined} */ -let containerElement; -/** @type {Array<(element: HTMLDivElement) => void>} */ -let onLoadQueue = []; -/** @type {TrustedTypePolicy | undefined} */ -let overlayTrustedTypesPolicy; - ansiHTML.setColors(colors); -/** - * - * @param {HTMLElement} element - * @param {CSSStyleDeclaration} style - */ -function applyStyle(element, style) { - Object.keys(style).forEach((prop) => { - element.style[prop] = style[prop]; - }); -} - -/** - * @param {string | null} trustedTypesPolicyName - */ -function createContainer(trustedTypesPolicyName) { - // Enable Trusted Types if they are available in the current browser. - if (window.trustedTypes) { - overlayTrustedTypesPolicy = window.trustedTypes.createPolicy( - trustedTypesPolicyName || "webpack-dev-server#overlay", - { - createHTML: (value) => value, - } - ); - } - - iframeContainerElement = document.createElement("iframe"); - iframeContainerElement.id = "webpack-dev-server-client-overlay"; - iframeContainerElement.src = "about:blank"; - applyStyle(iframeContainerElement, iframeStyle); - iframeContainerElement.onload = () => { - containerElement = - /** @type {Document} */ - ( - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).contentDocument - ).createElement("div"); - - containerElement.id = "webpack-dev-server-client-overlay-div"; - applyStyle(containerElement, containerStyle); - - const 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", () => { - hide(); - }); - - containerElement.appendChild(headerElement); - containerElement.appendChild(closeButtonElement); - - /** @type {Document} */ - ( - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).contentDocument - ).body.appendChild(containerElement); - - onLoadQueue.forEach((onLoad) => { - onLoad(/** @type {HTMLDivElement} */ (containerElement)); - }); - onLoadQueue = []; - - /** @type {HTMLIFrameElement} */ - (iframeContainerElement).onload = null; - }; - - document.body.appendChild(iframeContainerElement); -} - -/** - * @param {(element: HTMLDivElement) => void} callback - * @param {string | null} trustedTypesPolicyName - */ -function ensureOverlayExists(callback, trustedTypesPolicyName) { - if (containerElement) { - // Everything is ready, call the callback right away. - callback(containerElement); - - return; - } - - onLoadQueue.push(callback); - - if (iframeContainerElement) { - return; - } - - createContainer(trustedTypesPolicyName); -} - -// Successful compilation. -function hide() { - if (!iframeContainerElement) { - return; - } - - // Clean up and reset internal state. - document.body.removeChild(iframeContainerElement); - - iframeContainerElement = null; - containerElement = null; -} - /** * @param {string} type * @param {string | { file?: string, moduleName?: string, loc?: string, message?: string }} item @@ -181,54 +65,212 @@ function formatProblem(type, item) { return { header, body }; } -// Compilation with errors (e.g. syntax error or missing modules). /** - * @param {string} type - * @param {Array} messages - * @param {string | null} trustedTypesPolicyName + * @typedef {Object} CreateOverlayOptions + * @property {string | null} trustedTypesPolicyName */ -function show(type, messages, trustedTypesPolicyName) { - ensureOverlayExists(() => { - 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", + +/** + * + * @param {CreateOverlayOptions} options + */ +const createOverlay = (options) => { + /** @type {HTMLIFrameElement | null | undefined} */ + let iframeContainerElement; + /** @type {HTMLDivElement | null | undefined} */ + let containerElement; + /** @type {Array<(element: HTMLDivElement) => void>} */ + let onLoadQueue = []; + /** @type {TrustedTypePolicy | undefined} */ + let overlayTrustedTypesPolicy; + + /** + * + * @param {HTMLElement} element + * @param {CSSStyleDeclaration} style + */ + function applyStyle(element, style) { + Object.keys(style).forEach((prop) => { + element.style[prop] = style[prop]; + }); + } + + /** + * @param {string | null} trustedTypesPolicyName + */ + function createContainer(trustedTypesPolicyName) { + // Enable Trusted Types if they are available in the current browser. + if (window.trustedTypes) { + overlayTrustedTypesPolicy = window.trustedTypes.createPolicy( + trustedTypesPolicyName || "webpack-dev-server#overlay", + { + createHTML: (value) => value, + } + ); + } + + iframeContainerElement = document.createElement("iframe"); + iframeContainerElement.id = "webpack-dev-server-client-overlay"; + iframeContainerElement.src = "about:blank"; + applyStyle(iframeContainerElement, iframeStyle); + 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); + + const 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("DISMISS"); + }); + + contentElement.appendChild(headerElement); + contentElement.appendChild(closeButtonElement); + contentElement.appendChild(containerElement); + + /** @type {Document} */ + ( + /** @type {HTMLIFrameElement} */ + (iframeContainerElement).contentDocument + ).body.appendChild(contentElement); + + onLoadQueue.forEach((onLoad) => { + onLoad(/** @type {HTMLDivElement} */ (contentElement)); }); + onLoadQueue = []; + + /** @type {HTMLIFrameElement} */ + (iframeContainerElement).onload = null; + }; + + document.body.appendChild(iframeContainerElement); + } - const typeElement = document.createElement("div"); - const { header, body } = formatProblem(type, message); + /** + * @param {(element: HTMLDivElement) => void} callback + * @param {string | null} trustedTypesPolicyName + */ + function ensureOverlayExists(callback, trustedTypesPolicyName) { + if (containerElement) { + containerElement.innerHTML = ""; + // Everything is ready, call the callback right away. + callback(containerElement); - typeElement.innerText = header; - applyStyle(typeElement, msgTypeStyle); + return; + } - if (message.moduleIdentifier) { - applyStyle(typeElement, { cursor: "pointer" }); - typeElement.dataset.canOpen = true; - typeElement.addEventListener("click", () => { - fetch( - `/webpack-dev-server/open-editor?fileName=${message.moduleIdentifier}` - ); + onLoadQueue.push(callback); + + if (iframeContainerElement) { + return; + } + + createContainer(trustedTypesPolicyName); + } + + // Successful compilation. + function hide() { + if (!iframeContainerElement) { + return; + } + + // Clean up and reset internal state. + document.body.removeChild(iframeContainerElement); + + iframeContainerElement = null; + containerElement = null; + } + + // Compilation with errors (e.g. syntax error or missing modules). + /** + * @param {string} type + * @param {Array} messages + * @param {string | null} trustedTypesPolicyName + */ + function show(type, messages, trustedTypesPolicyName) { + ensureOverlayExists(() => { + 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", }); - } - // Make it look similar to our terminal. - const text = ansiHTML(encode(body)); - const messageTextNode = document.createElement("div"); - applyStyle(messageTextNode, msgTextStyle); + const typeElement = document.createElement("div"); + const { header, body } = formatProblem(type, message); + + typeElement.innerText = header; + applyStyle(typeElement, msgTypeStyle); + + if (message.moduleIdentifier) { + applyStyle(typeElement, { cursor: "pointer" }); + typeElement.dataset.canOpen = true; + typeElement.addEventListener("click", () => { + fetch( + `/webpack-dev-server/open-editor?fileName=${message.moduleIdentifier}` + ); + }); + } + + // Make it look similar to our terminal. + const text = ansiHTML(encode(body)); + const messageTextNode = document.createElement("div"); + applyStyle(messageTextNode, msgTextStyle); - messageTextNode.innerHTML = overlayTrustedTypesPolicy - ? overlayTrustedTypesPolicy.createHTML(text) - : text; + messageTextNode.innerHTML = overlayTrustedTypesPolicy + ? overlayTrustedTypesPolicy.createHTML(text) + : text; - entryElement.appendChild(typeElement); - entryElement.appendChild(messageTextNode); + entryElement.appendChild(typeElement); + entryElement.appendChild(messageTextNode); - /** @type {HTMLDivElement} */ - (containerElement).appendChild(entryElement); + /** @type {HTMLDivElement} */ + (containerElement).appendChild(entryElement); + }); + }, trustedTypesPolicyName); + } + + const overlayMachine = createOverlayMachine({ + showOverlay: ({ level = "error", messages }) => + show(level, messages, options.trustedTypesPolicyName), + hideOverlay: hide, + }); + + const overlayService = interpret(overlayMachine).start(); + + listenToRuntimeError((err) => { + console.log(err); + overlayService.send({ + type: "RUNTIME_ERROR", + messages: [err.message], }); - }, trustedTypesPolicyName); -} + }); + + return overlayService; +}; -export { formatProblem, show, hide }; +export { formatProblem, createOverlay }; diff --git a/client-src/overlay/runtime-error.js b/client-src/overlay/runtime-error.js new file mode 100644 index 0000000000..3c0210d6d3 --- /dev/null +++ b/client-src/overlay/runtime-error.js @@ -0,0 +1,20 @@ +/** + * @callback ErrorCallback + * @param {ErrorEvent} error + * @returns {void} + */ + +/** + * @param {ErrorCallback} callback + */ +function listenToRuntimeError(callback) { + window.addEventListener("error", callback); + + return function cleanup() { + window.removeEventListener("error", callback); + }; +} + +function listenToSomething() {} + +export { listenToRuntimeError, listenToSomething }; diff --git a/client-src/overlay/state-machine.js b/client-src/overlay/state-machine.js new file mode 100644 index 0000000000..5f322daa3f --- /dev/null +++ b/client-src/overlay/state-machine.js @@ -0,0 +1,89 @@ +import { createMachine, assign } from "@xstate/fsm"; + +/** + * @typedef {Object} ShowOverlayData + * @property {'warning' | 'error'} level + * @property {Array} messages + */ + +/** + * @typedef {Object} CreateOverlayMachineOptions + * @property {(data: ShowOverlayData) => void} showOverlay + * @property {() => void} hideOverlay + */ + +/** + * @param {CreateOverlayMachineOptions} options + */ +const createOverlayMachine = (options) => { + const { hideOverlay, showOverlay } = options; + const overlayMachine = createMachine( + { + id: "overlay", + initial: "hidden", + context: { + level: "error", + messages: [], + }, + states: { + hidden: { + entry: "hideOverlay", + on: { + BUILD_ERROR: { + target: "displayBuildError", + actions: ["setMessages", "showOverlay"], + }, + RUNTIME_ERROR: { + target: "displayRuntimeError", + actions: ["setMessages", "showOverlay"], + }, + }, + }, + displayBuildError: { + on: { + DISMISS: { target: "hidden", actions: "dismissMessages" }, + BUILD_ERROR: { + target: "displayBuildError", + actions: ["appendMessages", "showOverlay"], + }, + }, + }, + displayRuntimeError: { + on: { + DISMISS: { target: "hidden", actions: "dismissMessages" }, + RUNTIME_ERROR: { + target: "displayRuntimeError", + actions: ["appendMessages", "showOverlay"], + }, + BUILD_ERROR: { + target: "displayBuildError", + actions: ["setMessages", "showOverlay"], + }, + }, + }, + }, + }, + { + actions: { + dismissMessages: assign({ + messages: [], + level: "error", + }), + appendMessages: assign({ + messages: (context, event) => context.messages.concat(event.messages), + level: (context, event) => event.level || context.level, + }), + setMessages: assign({ + messages: (_, event) => event.messages, + level: (context, event) => event.level || context.level, + }), + hideOverlay, + showOverlay, + }, + } + ); + + return overlayMachine; +}; + +export default createOverlayMachine; diff --git a/examples/client/overlay/app.js b/examples/client/overlay/app.js index 77142167f0..a4344aa340 100644 --- a/examples/client/overlay/app.js +++ b/examples/client/overlay/app.js @@ -1,7 +1,12 @@ "use strict"; +// eslint-disable-next-line import/order +const createErrorBtn = require("./error-button"); + const target = document.querySelector("#target"); +target.insertAdjacentElement("afterend", createErrorBtn()); + // eslint-disable-next-line import/no-unresolved, import/extensions const invalid = require("./invalid.js"); diff --git a/examples/client/overlay/error-button.js b/examples/client/overlay/error-button.js new file mode 100644 index 0000000000..dcd695b67e --- /dev/null +++ b/examples/client/overlay/error-button.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = function createErrorButton() { + const errorBtn = document.createElement("button"); + + errorBtn.addEventListener("click", () => { + throw new Error("runtime error!"); + }); + errorBtn.innerHTML = "Click to throw error"; + + return errorBtn; +}; diff --git a/package-lock.json b/package-lock.json index ef55211816..a5e41f09c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/serve-static": "^1.13.10", "@types/sockjs": "^0.3.33", "@types/ws": "^8.5.1", + "@xstate/fsm": "^2.0.0", "ansi-html-community": "^0.0.8", "bonjour-service": "^1.0.11", "chokidar": "^3.5.3", @@ -3998,6 +3999,11 @@ } } }, + "node_modules/@xstate/fsm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-2.0.0.tgz", + "integrity": "sha512-p/zcvBMoU2ap5byMefLkR+AM+Eh99CU/SDEQeccgKlmFNOMDwphaRGqdk+emvel/SaGZ7Rf9sDvzAplLzLdEVQ==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -18764,6 +18770,11 @@ "dev": true, "requires": {} }, + "@xstate/fsm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-2.0.0.tgz", + "integrity": "sha512-p/zcvBMoU2ap5byMefLkR+AM+Eh99CU/SDEQeccgKlmFNOMDwphaRGqdk+emvel/SaGZ7Rf9sDvzAplLzLdEVQ==" + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", diff --git a/package.json b/package.json index 0ea6a2d3c7..78e9dccbd4 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/serve-static": "^1.13.10", "@types/sockjs": "^0.3.33", "@types/ws": "^8.5.1", + "@xstate/fsm": "^2.0.0", "ansi-html-community": "^0.0.8", "bonjour-service": "^1.0.11", "chokidar": "^3.5.3",