Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-postmessage-and-reload-guards.md
Original file line number Diff line number Diff line change
@@ -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<hash>` — so a plugin or a framework's dev tooling can follow a build without reaching into the client
5 changes: 5 additions & 0 deletions .changeset/reload-guards.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/runtime-error-cause.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions client-src/clients/createSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand Down Expand Up @@ -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();
Comment thread
alexander-akait marked this conversation as resolved.
}

if (closed || attempt >= retries) {
return;
}
Expand Down
24 changes: 21 additions & 3 deletions client-src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -238,6 +239,9 @@ function createClientSocket() {
retryDelay: isEventSource
? () => /** @type {number} */ (options.timeout)
: undefined,
onDisconnect: () => {
sendMessage("Close");
},
});
}

Expand Down Expand Up @@ -469,6 +473,7 @@ function processMessage(obj) {
lastBuildingName,
);
}
sendMessage("Invalid");
break;
}
case "progress": {
Expand All @@ -481,6 +486,7 @@ function processMessage(obj) {
lastBuildingName,
);
}
sendMessage("Progress", obj);
break;
}
case "built":
Expand All @@ -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;
Expand Down
22 changes: 20 additions & 2 deletions client-src/overlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
90 changes: 89 additions & 1 deletion client-src/utils/reload.js
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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;
}
Comment thread
alexander-akait marked this conversation as resolved.

// 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;
});
}
48 changes: 48 additions & 0 deletions client-src/utils/send-message.js
Original file line number Diff line number Diff line change
@@ -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, "*");
};
Loading
Loading