diff --git a/.changeset/client-postmessage-and-reload-guards.md b/.changeset/client-postmessage-and-reload-guards.md new file mode 100644 index 000000000..410d0ce2a --- /dev/null +++ b/.changeset/client-postmessage-and-reload-guards.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +Post build events to the page the way webpack-dev-server's client does — `webpackInvalid`, `webpackProgress`, `webpackOk`, `webpackStillOk`, `webpackWarnings`, `webpackErrors`, `webpackClose` and `webpackHotUpdate` — so a plugin or a framework's dev tooling can follow a build without reaching into the client diff --git a/.changeset/reload-guards.md b/.changeset/reload-guards.md new file mode 100644 index 000000000..02182d46e --- /dev/null +++ b/.changeset/reload-guards.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Do not reload a page that is already navigating away, and reload the nearest ancestor that has a url of its own when the app runs in an `about:blank` iframe diff --git a/.changeset/runtime-error-cause.md b/.changeset/runtime-error-cause.md new file mode 100644 index 000000000..ae4315e16 --- /dev/null +++ b/.changeset/runtime-error-cause.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Give an `overlay.runtimeErrors` filter the rejected value through `error.cause`, so a rejection carrying a plain object rather than an `Error` can still be judged on what it carries diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js index 4d8e3b86e..b5410db1f 100644 --- a/client-src/clients/createSocket.js +++ b/client-src/clients/createSocket.js @@ -29,6 +29,7 @@ import { log } from "../utils/log.js"; * @property {number=} retries how many times to reconnect before giving up, `Infinity` to keep trying * @property {((attempt: number) => number)=} retryDelay how long to wait before the attempt, in milliseconds * @property {boolean=} logRetries say so before each attempt, which only a bounded number of them can afford to do + * @property {(() => void)=} onDisconnect called once per outage — on the first drop, whether or not that connection ever opened * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ @@ -77,6 +78,17 @@ export default function createSocket(Client, url, options = {}) { client.onClose(() => { client = null; + // Once per outage rather than once per failed attempt: the retries that + // follow are this module reconnecting, not the connection going away + // again. `attempt` is back to zero for every connection that opened. + // + // A first attempt that never opened reports too, which is deliberate: a + // page loaded while the server is down has no connection either, and + // saying so is what webpack-dev-server's client has always done. + if (!closed && attempt === 0 && options.onDisconnect) { + options.onDisconnect(); + } + if (closed || attempt >= retries) { return; } diff --git a/client-src/index.js b/client-src/index.js index 876af591e..0f7a54574 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -16,6 +16,7 @@ import * as indicator from "./indicator.js"; import configureOverlay from "./overlay.js"; import applyUpdate from "./process-update.js"; import { log, setLogLevel } from "./utils/log.js"; +import sendMessage from "./utils/send-message.js"; import stripAnsi from "./utils/strip-ansi.js"; /** @typedef {import("./utils/log.js").LogLevel} LogLevel */ @@ -238,6 +239,9 @@ function createClientSocket() { retryDelay: isEventSource ? () => /** @type {number} */ (options.timeout) : undefined, + onDisconnect: () => { + sendMessage("Close"); + }, }); } @@ -469,6 +473,7 @@ function processMessage(obj) { lastBuildingName, ); } + sendMessage("Invalid"); break; } case "progress": { @@ -481,6 +486,7 @@ function processMessage(obj) { lastBuildingName, ); } + sendMessage("Progress", obj); break; } case "built": @@ -503,17 +509,29 @@ function processMessage(obj) { if (obj.errors.length > 0) { if (reporter) reporter.problems("errors", obj); shouldApply = false; + sendMessage("Errors", obj.errors); } 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) { reporter.problems("warnings", obj); } - } else if (reporter) { - reporter.cleanProblemsCache(obj.name || ""); - reporter.success(obj); + sendMessage("Warnings", obj.warnings); + } else { + if (reporter) { + reporter.cleanProblemsCache(obj.name || ""); + reporter.success(obj); + } + // `built` is a compilation that produced something, `sync` one that + // had nothing to report — the same distinction webpack-dev-server + // draws between `Ok` and `StillOk`. + sendMessage(obj.action === "built" ? "Ok" : "StillOk"); } if (shouldApply) { + // Posted before the update is applied, in the shape + // webpack-dev-server has always used for this one — a bare string + // rather than the `{ type, data }` the others carry. + sendMessage.raw(`webpackHotUpdate${obj.hash}`); applyUpdate(obj.hash, options, obj.name); } break; diff --git a/client-src/overlay.js b/client-src/overlay.js index 3a02ceff5..a78dad119 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -855,8 +855,26 @@ function handleRuntimeError(error, fallbackMessage) { return; } - const errorObject = - error instanceof Error ? error : new Error(error || fallbackMessage); + // A rejection carries whatever it was rejected with, which is often a plain + // object rather than an `Error`. Wrapping it keeps a message to render, and + // `cause` keeps the value itself reachable — a `catchRuntimeError` filter + // deciding on a status code has nowhere else to read it from. + const wrapped = !(error instanceof Error); + const errorObject = wrapped + ? new Error(error || fallbackMessage) + : /** @type {Error} */ (error); + + // Not the constructor's `cause` option: that is ES2022, and this file is + // compiled to an ES5 baseline for browsers that predate it — where the option + // is quietly ignored and the filter would find nothing. Defined rather than + // assigned so it stays non-enumerable, as the option makes it. + if (wrapped) { + Object.defineProperty(errorObject, "cause", { + configurable: true, + value: error, + writable: true, + }); + } // `catchRuntimeError` may be a filter function, like in webpack-dev-server. const shouldDisplay = diff --git a/client-src/utils/reload.js b/client-src/utils/reload.js index fd0f83261..860d57c73 100644 --- a/client-src/utils/reload.js +++ b/client-src/utils/reload.js @@ -1,7 +1,95 @@ +// Set while the page is on its way out, so an update that lands mid-navigation +// does not reload the page the browser is already leaving. A `beforeunload` can +// be cancelled — by another listener, or by the user answering "stay" — so the +// flag is released again shortly after, and `pagehide`/`pageshow` settle the +// cases `beforeunload` gets wrong (a page kept in the back/forward cache runs +// the same script again when it comes back). +const UNLOAD_GRACE_PERIOD = 1000; + +let unloading = false; +// A reload asked for while the page looked like it was leaving. Held rather +// than dropped: if the navigation was cancelled the page is staying and still +// wants the update, and nothing would ask again until the next rebuild. +let deferred = false; +/** @type {ReturnType | undefined} */ +let graceTimer; + /** + * @returns {boolean} whether the page is on its way out + */ +export function isUnloading() { + return unloading; +} + +/** + * Reload the page. While it looks like the page is leaving, the reload is held + * until that turns out to be wrong rather than performed or thrown away. * Isolated so tests can stub it — `window.location` is not configurable in * modern jsdom. */ export default function reloadPage() { - window.location.reload(); + if (unloading) { + deferred = true; + + return; + } + + // In an iframe with no navigable url of its own — `srcdoc`, or a document + // written into it — reloading would reload `about:blank` and lose the app, so + // the nearest ancestor that has somewhere to go back to is reloaded instead. + /** @type {Window} */ + let target = window; + + try { + while ( + target.location.protocol === "about:" && + target.parent && + target.parent !== target + ) { + target = target.parent; + } + } catch { + // A cross-origin ancestor: its location cannot be read, let alone + // reloaded. Reloading this frame is the most that is permitted here. + target = window; + } + + target.location.reload(); +} + +if (typeof window !== "undefined" && window.addEventListener) { + window.addEventListener("beforeunload", () => { + unloading = true; + + clearTimeout(graceTimer); + + graceTimer = setTimeout(() => { + unloading = false; + + if (deferred) { + deferred = false; + reloadPage(); + } + }, UNLOAD_GRACE_PERIOD); + }); + + // The page really is going now, so stop reloading it for good — and drop + // anything held, or it would fire into a document on its way out. + window.addEventListener("pagehide", () => { + clearTimeout(graceTimer); + + unloading = true; + deferred = false; + }); + + // Restored from the back/forward cache: the same script keeps running, so a + // flag left set by the navigation away would block every later update. The + // page is showing its own state again, so a reload held from before that + // navigation is stale and goes no further. + window.addEventListener("pageshow", () => { + clearTimeout(graceTimer); + + unloading = false; + deferred = false; + }); } diff --git a/client-src/utils/send-message.js b/client-src/utils/send-message.js new file mode 100644 index 000000000..96b2951dd --- /dev/null +++ b/client-src/utils/send-message.js @@ -0,0 +1,48 @@ +/* global WorkerGlobalScope */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * Whether there is a page to talk to at all. A worker has none — and + * `WorkerGlobalScope` is not declared where there is no worker, hence the + * `typeof` guard on it. + * @returns {boolean} true when `postMessage` reaches a page + */ +function canPost() { + return ( + typeof self !== "undefined" && + (typeof WorkerGlobalScope === "undefined" || + !(self instanceof WorkerGlobalScope)) + ); +} + +/** + * Announce what the client just handled to whoever else is on the page, so a + * plugin or a framework's dev tooling can follow a build without reaching into + * this module. The `webpack` prefix and the payloads match what + * webpack-dev-server's client has always posted, because the consumers of + * these messages are the same ones. + * @param {string} type message type, without the `webpack` prefix + * @param {EXPECTED_ANY=} data payload + */ +export default function sendMessage(type, data) { + if (!canPost()) { + return; + } + + self.postMessage({ type: `webpack${type}`, data }, "*"); +} + +/** + * Post a message exactly as given, for the one webpack-dev-server sends as a + * bare string rather than in the `{ type, data }` shape. + * @param {EXPECTED_ANY} message the message to post + */ +sendMessage.raw = (message) => { + if (!canPost()) { + return; + } + + self.postMessage(message, "*"); +}; diff --git a/test/client-reload.test.js b/test/client-reload.test.js new file mode 100644 index 000000000..0b3bd3c86 --- /dev/null +++ b/test/client-reload.test.js @@ -0,0 +1,172 @@ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_OBJECT */ + +/** + * Stand in for the page. The module registers its listeners when it is first + * imported, so this has to be in place before the import — hence the + * `isolateModules` dance in each case below. + * @param {string=} protocol what `location.protocol` reports + * @returns {EXPECTED_OBJECT} the fake window and what it recorded + */ +function fakeWindow(protocol = "http:") { + /** @type {Record void)[]>} */ + const listeners = {}; + const reload = jest.fn(); + + /** @type {EXPECTED_OBJECT} */ + const win = { + addEventListener(type, fn) { + listeners[type] ||= []; + listeners[type].push(fn); + }, + location: { protocol, reload }, + }; + + win.parent = win; + globalThis.window = win; + + return { + reload, + /** @param {string} type event type */ + emit(type) { + for (const fn of listeners[type] || []) { + fn(); + } + }, + }; +} + +/** + * @returns {EXPECTED_OBJECT} a freshly imported copy of the module + */ +function loadModule() { + /** @type {EXPECTED_OBJECT} */ + let loaded; + + jest.isolateModules(() => { + loaded = require("../client-src/utils/reload.js"); + }); + + return loaded; +} + +describe("reloadPage", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + delete globalThis.window; + }); + + it("reloads straight away when nothing is going on", () => { + const page = fakeWindow(); + const { default: reloadPage } = loadModule(); + + reloadPage(); + + expect(page.reload).toHaveBeenCalledTimes(1); + }); + + it("holds a reload while the page looks like it is leaving", () => { + const page = fakeWindow(); + const { default: reloadPage, isUnloading } = loadModule(); + + page.emit("beforeunload"); + + expect(isUnloading()).toBe(true); + + reloadPage(); + + // Reloading a page the browser is already leaving is pointless at best. + expect(page.reload).not.toHaveBeenCalled(); + }); + + it("performs the held reload when the navigation was cancelled", () => { + const page = fakeWindow(); + const { default: reloadPage } = loadModule(); + + page.emit("beforeunload"); + reloadPage(); + + // The page is still here, so it was a cancelled navigation — the update it + // asked for is still wanted, and nothing else would ask again until the + // next rebuild. + jest.advanceTimersByTime(1000); + + expect(page.reload).toHaveBeenCalledTimes(1); + }); + + it("drops a held reload once the page really goes", () => { + const page = fakeWindow(); + const { default: reloadPage } = loadModule(); + + page.emit("beforeunload"); + reloadPage(); + page.emit("pagehide"); + jest.advanceTimersByTime(10000); + + expect(page.reload).not.toHaveBeenCalled(); + }); + + it("drops a held reload when the page comes back from the cache", () => { + const page = fakeWindow(); + const { default: reloadPage, isUnloading } = loadModule(); + + page.emit("beforeunload"); + reloadPage(); + // Restored from the back/forward cache: it is showing its own state again, + // so a reload held from before the navigation is stale. + page.emit("pageshow"); + jest.advanceTimersByTime(10000); + + expect(isUnloading()).toBe(false); + expect(page.reload).not.toHaveBeenCalled(); + }); + + it("stops blocking updates after the page comes back", () => { + const page = fakeWindow(); + const { default: reloadPage } = loadModule(); + + page.emit("beforeunload"); + page.emit("pagehide"); + // Without `pageshow` clearing it, the flag left set by navigating away + // would block every later update for the life of the script. + page.emit("pageshow"); + + reloadPage(); + + expect(page.reload).toHaveBeenCalledTimes(1); + }); + + it("reloads an ancestor when this frame has no url of its own", () => { + const page = fakeWindow("about:"); + const { default: reloadPage } = loadModule(); + const top = { location: { protocol: "https:", reload: jest.fn() } }; + + /** @type {EXPECTED_OBJECT} */ (top).parent = top; + globalThis.window.parent = top; + + reloadPage(); + + // Reloading `about:blank` would lose the app entirely. + expect(page.reload).not.toHaveBeenCalled(); + expect(top.location.reload).toHaveBeenCalledTimes(1); + }); + + it("settles for this frame when an ancestor cannot be read", () => { + const page = fakeWindow("about:"); + const { default: reloadPage } = loadModule(); + + Object.defineProperty(globalThis.window, "parent", { + get() { + throw new Error("cross-origin"); + }, + }); + + reloadPage(); + + expect(page.reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/e2e/messages.test.js b/test/e2e/messages.test.js new file mode 100644 index 000000000..9b8b81a69 --- /dev/null +++ b/test/e2e/messages.test.js @@ -0,0 +1,208 @@ +import { + acceptedApp, + boomApp, + closeE2e, + waitForAppText, + waitForOverlay, + waitForRuntimeListeners, +} from "../helpers/e2e"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +/** + * An app that records everything the client posts to the page, so the test can + * read it back. Registered from the app entry, which webpack runs after the + * client entry — every message of interest arrives later than that, once the + * transport has connected. + * @param {string} text app text + * @returns {string} fixture source + */ +function recordingApp(text) { + // The recorder has to outlive the module: this entry accepts its own + // updates, so it re-executes on every hot update and would otherwise throw + // away everything posted before the one being tested. + return ` + if (!globalThis.posted) { + globalThis.posted = []; + window.addEventListener("message", (event) => { + globalThis.posted.push(event.data); + }); + } + ${acceptedApp(text)} + `; +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} the `type` of every posted message, in order + */ +function postedTypes(page) { + return page.evaluate(() => + (globalThis.posted || []).map((message) => + typeof message === "string" ? message : message && message.type, + ), + ); +} + +describe("messages posted to the page (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("announces a build the way webpack-dev-server's client does", async () => { + hotApp = await createHotApp({ code: recordingApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + // The catch-up sync a newly connected client is sent reports a + // compilation that had nothing to say. + await page.waitForFunction( + () => + (globalThis.posted || []).some( + (message) => message && message.type === "webpackStillOk", + ), + { timeout: 30000 }, + ); + + hotApp.edit(recordingApp("v2")); + await waitForAppText(page, "v2"); + + const types = await postedTypes(page); + + // A rebuild starting, then the successful result, then the hash the + // update is being applied from — the names and the order plugins have + // always consumed. + expect(types).toContain("webpackInvalid"); + expect(types).toContain("webpackOk"); + expect(types.some((type) => /^webpackHotUpdate[\da-f]+$/.test(type))).toBe( + true, + ); + expect(types.indexOf("webpackInvalid")).toBeLessThan( + types.indexOf("webpackOk"), + ); + }); + + it("carries the problems a build reported", async () => { + hotApp = await createHotApp({ code: recordingApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit("this is not valid javascript {{{"); + await waitForOverlay(page); + + // Not just the name: a consumer reads the messages off the payload. + const errors = await page.evaluate(() => { + const message = (globalThis.posted || []).find( + (posted) => posted && posted.type === "webpackErrors", + ); + + return message ? message.data : null; + }); + + expect(Array.isArray(errors)).toBe(true); + expect(errors.join("\n")).toContain("Module parse failed"); + }); + + it("says when the connection went away, once per outage", async () => { + hotApp = await createHotApp({ + query: "?timeout=1000", + code: recordingApp("v1"), + hot: { heartbeat: 300 }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await page.waitForFunction( + () => + (globalThis.posted || []).some( + (message) => message && message.type === "webpackStillOk", + ), + { timeout: 30000 }, + ); + + await hotApp.stopHttp(); + + await page.waitForFunction( + () => + (globalThis.posted || []).some( + (message) => message && message.type === "webpackClose", + ), + { timeout: 30000 }, + ); + + // Server-Sent Events retry for as long as the page is open, so a closed + // server would keep announcing itself if this counted attempts rather + // than outages. + await new Promise((resolve) => { + setTimeout(resolve, 3000); + }); + + const closes = (await postedTypes(page)).filter( + (type) => type === "webpackClose", + ); + + expect(closes).toHaveLength(1); + + await hotApp.startHttp(); + }); +}); + +describe("runtime error filtering (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("gives a filter the rejected value through `cause`", async () => { + hotApp = await createHotApp({ + // Rejects with a plain object rather than an `Error`, so the only way to + // reach what it carries is `cause` on the error the overlay built. + query: `?${new URLSearchParams({ + overlay: JSON.stringify({ + runtimeErrors: encodeURIComponent( + "function(error){return Boolean(error.cause) && error.cause.status === 503}", + ), + }), + })}`, + code: ` + ${boomApp("v1")} + globalThis.rejectWith = (value) => { + setTimeout(() => { + Promise.reject(value); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await waitForRuntimeListeners(page); + + // A status the filter rejects: nothing appears. + await page.evaluate(() => globalThis.rejectWith({ status: 500 })); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.$("#webpack-dev-middleware-hot-overlay")).toBeNull(); + + // The one it accepts, read from the same place. + await page.evaluate(() => globalThis.rejectWith({ status: 503 })); + await waitForOverlay(page); + }); +});