Skip to content

Commit 1abd3b2

Browse files
committed
fix(client): reload when an accept handler errors during apply (#2360)
* fix(client): reload when an accept handler errors during apply An error thrown inside a module.hot.accept handler was logged and ignored, leaving the page running stale code with no recovery. When the `reload` option is enabled (the default), onErrored now falls back to a full page reload, like the other unrecoverable update paths. The HMR runtime and page-reload calls are isolated behind small utils so process-update finally has direct test coverage. A missing HMR runtime is now reported with a single actionable error instead of throwing at bundle evaluation. Ref webpack/webpack-hot-middleware#334 * fixup! * fixup!
1 parent 29d46b7 commit 1abd3b2

2 files changed

Lines changed: 65 additions & 23 deletions

File tree

client-src/process-update.js

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,15 @@ import getHot from "./utils/get-hot.js";
44
import { log } from "./utils/log.js";
55
import reloadPage from "./utils/reload.js";
66

7-
const maybeHot = getHot();
8-
9-
if (!maybeHot) {
10-
throw new Error("[HMR] Hot Module Replacement is disabled.");
11-
}
12-
13-
const hot = maybeHot;
14-
157
const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/";
168

179
/** @type {string | undefined} */
1810
let lastHash;
1911
/** @type {Record<string, number>} */
2012
const failureStatuses = { abort: 1, fail: 1 };
13+
// Set per applyUpdate() call from the client's `reload` option.
14+
let reloadOnErrored = false;
15+
let loggedRuntimeMissing = false;
2116

2217
/** @type {webpack.ApplyOptions} */
2318
const applyOptions = {
@@ -39,6 +34,12 @@ const applyOptions = {
3934
log.warn(
4035
`Ignored an error while updating module ${event.moduleId} (${event.type})`,
4136
);
37+
// An error thrown inside an accept handler leaves the app in an
38+
// undefined state — fall back to a full page reload.
39+
if (reloadOnErrored) {
40+
log.warn("Reloading page");
41+
reloadPage();
42+
}
4243
},
4344
};
4445

@@ -56,8 +57,25 @@ function upToDate(hash) {
5657
* @param {{ reload?: boolean }} options client options
5758
*/
5859
export default function applyUpdate(hash, options) {
60+
const hot = getHot();
61+
62+
if (!hot) {
63+
// Logged (once) instead of thrown: this runs inside the SSE message
64+
// handler, whose catch would mislabel a throw as an invalid message.
65+
if (!loggedRuntimeMissing) {
66+
loggedRuntimeMissing = true;
67+
log.error(
68+
"[HMR] Hot Module Replacement is disabled. " +
69+
"Add HotModuleReplacementPlugin to the webpack configuration.",
70+
);
71+
}
72+
return;
73+
}
74+
5975
const { reload } = options;
6076

77+
reloadOnErrored = Boolean(reload);
78+
6179
/**
6280
* Trigger a full page reload when HMR cannot apply the update.
6381
*/
@@ -72,6 +90,7 @@ export default function applyUpdate(hash, options) {
7290
* @param {Error} err error
7391
*/
7492
function handleError(err) {
93+
// @ts-expect-error function declarations are hoisted, so the `!hot` guard narrowing is lost
7594
if (hot.status() in failureStatuses) {
7695
log.warn("Cannot check for update (Full reload needed)");
7796
log.warn(err.stack || err.message);
@@ -126,6 +145,7 @@ export default function applyUpdate(hash, options) {
126145
* Ask webpack for the next chunk of HMR updates and apply them.
127146
*/
128147
function check() {
148+
// @ts-expect-error function declarations are hoisted, so the `!hot` guard narrowing is lost
129149
hot
130150
.check(false)
131151
.then((updatedModules) => {
@@ -136,6 +156,7 @@ export default function applyUpdate(hash, options) {
136156
return undefined;
137157
}
138158

159+
// @ts-expect-error function declarations are hoisted, so the `!hot` guard narrowing is lost
139160
return hot.apply(applyOptions).then((renewedModules) => {
140161
if (!upToDate()) check();
141162
logUpdates(updatedModules, renewedModules);

test/process-update.test.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,9 @@ describe("process-update", () => {
4646
}
4747

4848
/**
49-
* Load a fresh process-update with the given fake runtime. The runtime is
50-
* resolved at module evaluation, so the mock must be configured in the same
51-
* module registry before process-update is required.
52-
* @param {FakeHot} hot fake `import.meta.webpackHot`
49+
* Load a fresh process-update with the given fake runtime configured in the
50+
* same module registry.
51+
* @param {FakeHot | undefined} hot fake `import.meta.webpackHot`
5352
* @returns {typeof import("../client-src/process-update").default} applyUpdate
5453
*/
5554
function loadApplyUpdate(hot) {
@@ -84,17 +83,18 @@ describe("process-update", () => {
8483
jest.restoreAllMocks();
8584
});
8685

87-
it("throws at module evaluation when the HMR runtime is disabled", () => {
88-
jest.isolateModules(() => {
89-
/** @type {jest.Mock} */
90-
const isolatedGetHot = require("../client-src/utils/get-hot");
86+
it("logs an error once when the HMR runtime is disabled", () => {
87+
applyUpdate = loadApplyUpdate(undefined);
9188

92-
isolatedGetHot.mockReturnValue(undefined);
89+
expect(() => applyUpdate("h1", { reload: true })).not.toThrow();
90+
applyUpdate("h2", { reload: true });
9391

94-
expect(() => require("../client-src/process-update")).toThrow(
95-
/Hot Module Replacement is disabled/,
96-
);
97-
});
92+
const runtimeErrors = console.error.mock.calls.filter((call) =>
93+
call.join(" ").includes("Hot Module Replacement is disabled"),
94+
);
95+
96+
expect(runtimeErrors).toHaveLength(1);
97+
expect(reloadPage).not.toHaveBeenCalled();
9898
});
9999

100100
it("checks and applies an update when the hash differs", async () => {
@@ -124,7 +124,7 @@ describe("process-update", () => {
124124
expect(reloadPage).toHaveBeenCalledTimes(1);
125125
});
126126

127-
it("ignores an error thrown by an accept handler during apply", async () => {
127+
it("reloads when an accept handler errors during apply (onErrored)", async () => {
128128
applyUpdate = loadApplyUpdate(
129129
makeFakeHot({
130130
applyImpl: (applyOptions) => {
@@ -143,7 +143,28 @@ describe("process-update", () => {
143143
globalThis.__webpack_hash__ = "new-hash";
144144
await flushPromises();
145145

146-
// The error is logged but does not trigger a reload.
146+
expect(reloadPage).toHaveBeenCalledTimes(1);
147+
});
148+
149+
it("does not reload on errored apply when reload is disabled", async () => {
150+
applyUpdate = loadApplyUpdate(
151+
makeFakeHot({
152+
applyImpl: (applyOptions) => {
153+
applyOptions.onErrored({
154+
error: new Error("accept handler failed"),
155+
moduleId: "./a.js",
156+
type: "accept-errored",
157+
});
158+
159+
return Promise.resolve(["./a.js"]);
160+
},
161+
}),
162+
);
163+
164+
applyUpdate("new-hash", { reload: false });
165+
globalThis.__webpack_hash__ = "new-hash";
166+
await flushPromises();
167+
147168
expect(reloadPage).not.toHaveBeenCalled();
148169
});
149170

0 commit comments

Comments
 (0)