From 54be7ee061b3b8191fb779afdd2fd8d3d4ef6a49 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:43:01 +0000 Subject: [PATCH 1/2] feat(client): post build events to the page, and guard reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps against webpack-dev-server's client, found by comparing what survives if dev-server deletes its own and re-exports this one. Its client posts every build event out through `self.postMessage` "so plugins can consume it", and this one posted nothing at all — so anything listening for `webpackOk` and its siblings would have gone quiet without a word. Undocumented, which makes it easy to drop and hard to notice. All eight are posted now, under the same names and payloads, including the bare `webpackHotUpdate` string. `webpackClose` announces an outage once rather than once per retry, matching the `retries === 0` guard the other client reports it behind. A reload while the page is already navigating away reloads a page the browser is leaving, so `beforeunload` now holds reloads off — released again after a grace period, since a `beforeunload` can be cancelled, and settled by `pagehide`/`pageshow` for a page that comes back out of the back/forward cache. In an `about:blank` iframe a reload reloaded the blank document and lost the app; the nearest ancestor with a url of its own is reloaded instead, and a cross-origin one is left alone rather than thrown on. A runtime error filter could not see what a rejection carried: a plain object was wrapped in an `Error` and dropped. It is kept as `cause` now, so a filter deciding on a status code has somewhere to read it from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .../client-postmessage-and-reload-guards.md | 5 + .changeset/reload-guards.md | 5 + .changeset/runtime-error-cause.md | 5 + client-src/clients/createSocket.js | 8 + client-src/index.js | 24 +- client-src/overlay.js | 8 +- client-src/utils/reload.js | 76 ++++++- client-src/utils/send-message.js | 48 ++++ test/e2e/messages.test.js | 208 ++++++++++++++++++ 9 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 .changeset/client-postmessage-and-reload-guards.md create mode 100644 .changeset/reload-guards.md create mode 100644 .changeset/runtime-error-cause.md create mode 100644 client-src/utils/send-message.js create mode 100644 test/e2e/messages.test.js 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..0f4c15162 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, when a connection that was open goes away * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor */ @@ -77,6 +78,13 @@ 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. + 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..3ea5c97c8 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -855,8 +855,14 @@ function handleRuntimeError(error, fallbackMessage) { return; } + // 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 errorObject = - error instanceof Error ? error : new Error(error || fallbackMessage); + error instanceof Error + ? error + : new Error(error || fallbackMessage, { cause: error }); // `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..0973222fb 100644 --- a/client-src/utils/reload.js +++ b/client-src/utils/reload.js @@ -1,7 +1,77 @@ +// 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; +/** @type {ReturnType | undefined} */ +let graceTimer; + +if (typeof window !== "undefined" && window.addEventListener) { + window.addEventListener("beforeunload", () => { + unloading = true; + + clearTimeout(graceTimer); + + graceTimer = setTimeout(() => { + unloading = false; + }, UNLOAD_GRACE_PERIOD); + }); + + // The page really is going now, so stop reloading it for good. + window.addEventListener("pagehide", () => { + clearTimeout(graceTimer); + + unloading = true; + }); + + // 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. + window.addEventListener("pageshow", () => { + clearTimeout(graceTimer); + + unloading = false; + }); +} + +/** + * @returns {boolean} whether the page is on its way out + */ +export function isUnloading() { + return unloading; +} + /** - * Isolated so tests can stub it — `window.location` is not configurable in - * modern jsdom. + * Reload the page, unless it is already leaving. Isolated so tests can stub it + * — `window.location` is not configurable in modern jsdom. */ export default function reloadPage() { - window.location.reload(); + if (unloading) { + 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(); } 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/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); + }); +}); From 6ee3774159b0d29f9d0333d5834604dfb5943d46 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:31:10 +0000 Subject: [PATCH 2/2] fix(client): keep `cause` on old browsers, and hold a cancelled reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings. `new Error(message, { cause })` is ES2022, and this file is compiled to an ES5 baseline for browsers that predate it — where the option is quietly ignored and a `runtimeErrors` filter would find nothing to read. Defined afterwards instead, and non-enumerable, which is the descriptor the option would have given it. A reload asked for while the page looked like it was leaving was dropped. A `beforeunload` can be cancelled, and then the page is staying and still wants the update, with nothing to ask again until the next rebuild — so it is held for the grace period and performed when the navigation turns out not to have happened. Dropped for good on `pagehide`, where the page really is going, and on `pageshow`, where it came back from the cache showing its own state and a reload from before the navigation is stale. Covered by unit tests over a driven fake page, which is what makes the timing and the three events testable at all. A third finding, that `onDisconnect` reports a first attempt that never opened, is accurate but deliberate: webpack-dev-server reports that case too, from the same reset-on-open counter, and a page loaded while the server is down has no connection either. Its wording said "a connection that was open", which was wrong about its own behavior; the comment and the type now say what it does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/clients/createSocket.js | 6 +- client-src/overlay.js | 20 +++- client-src/utils/reload.js | 76 ++++++++----- test/client-reload.test.js | 172 +++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 34 deletions(-) create mode 100644 test/client-reload.test.js diff --git a/client-src/clients/createSocket.js b/client-src/clients/createSocket.js index 0f4c15162..b5410db1f 100644 --- a/client-src/clients/createSocket.js +++ b/client-src/clients/createSocket.js @@ -29,7 +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, when a connection that was open goes away + * @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 */ @@ -81,6 +81,10 @@ export default function createSocket(Client, url, options = {}) { // 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(); } diff --git a/client-src/overlay.js b/client-src/overlay.js index 3ea5c97c8..a78dad119 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -859,10 +859,22 @@ function handleRuntimeError(error, fallbackMessage) { // 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 errorObject = - error instanceof Error - ? error - : new Error(error || fallbackMessage, { cause: error }); + 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 0973222fb..860d57c73 100644 --- a/client-src/utils/reload.js +++ b/client-src/utils/reload.js @@ -7,36 +7,13 @@ 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; -if (typeof window !== "undefined" && window.addEventListener) { - window.addEventListener("beforeunload", () => { - unloading = true; - - clearTimeout(graceTimer); - - graceTimer = setTimeout(() => { - unloading = false; - }, UNLOAD_GRACE_PERIOD); - }); - - // The page really is going now, so stop reloading it for good. - window.addEventListener("pagehide", () => { - clearTimeout(graceTimer); - - unloading = true; - }); - - // 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. - window.addEventListener("pageshow", () => { - clearTimeout(graceTimer); - - unloading = false; - }); -} - /** * @returns {boolean} whether the page is on its way out */ @@ -45,11 +22,15 @@ export function isUnloading() { } /** - * Reload the page, unless it is already leaving. Isolated so tests can stub it - * — `window.location` is not configurable in modern jsdom. + * 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() { if (unloading) { + deferred = true; + return; } @@ -75,3 +56,40 @@ export default function reloadPage() { 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/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); + }); +});