Skip to content

Commit 1220b01

Browse files
committed
fix(hot): align statsOptions and heartbeat validation and pair duplicate-name bundles by occurrence
statsOptions now accepts only the object form everywhere: the schema rejected string presets the types allowed, and booleans validated but were silently ignored since toBundles only merges objects over the middleware's base stats options. Schema, JSDoc, and generated types all agree now, so invalid forms fail validation instead of at runtime. heartbeat: 0 was schema-valid but silently replaced with the 10s default by a falsy check — the schema now requires >= 1 and the code uses ?? so the option and its validation tell the same story.
1 parent c761c4a commit 1220b01

6 files changed

Lines changed: 87 additions & 23 deletions

File tree

src/hot.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
99
/** @typedef {import("./index.js").ServerResponse} ServerResponse */
1010

11-
/** @typedef {NonNullable<import("webpack").Configuration["stats"]>} StatsOptions */
11+
// The object form only (no presets/booleans) — it is merged over the
12+
// middleware's own base options, which string or boolean forms cannot be.
13+
/** @typedef {import("webpack").StatsOptions} StatsOptions */
1214

1315
/**
1416
* @typedef {object} HotOptions
@@ -219,7 +221,7 @@ function toBundles(statsResult, statsOptions) {
219221
timings: true,
220222
errors: true,
221223
warnings: true,
222-
...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}),
224+
...statsOptions,
223225
};
224226

225227
// Multi-compiler stats have stats for each child compiler.
@@ -257,18 +259,29 @@ function bundlePayload(stats, action) {
257259
* @param {EventStream} eventStream event stream
258260
*/
259261
function publishBundles(bundles, previousBundles, eventStream) {
262+
/** @type {Map<string, number>} */
263+
const occurrences = new Map();
264+
260265
for (const [index, stats] of bundles.entries()) {
261266
const name = stats.name || "";
262267

263268
// Paired by name so a changing set of compilations (children appearing,
264-
// config reloads) cannot compare a bundle against a sibling's hash;
265-
// unnamed bundles fall back to their position.
269+
// config reloads) cannot compare a bundle against a sibling's hash.
270+
// Webpack does not forbid duplicate names, so same-named bundles pair by
271+
// occurrence; unnamed bundles fall back to their position.
266272
let previous = null;
267273

268274
if (previousBundles !== null) {
269-
previous = name
270-
? previousBundles.find((bundle) => (bundle.name || "") === name) || null
271-
: previousBundles[index] || null;
275+
if (name) {
276+
const occurrence = occurrences.get(name) || 0;
277+
occurrences.set(name, occurrence + 1);
278+
previous =
279+
previousBundles.filter((bundle) => (bundle.name || "") === name)[
280+
occurrence
281+
] || null;
282+
} else {
283+
previous = previousBundles[index] || null;
284+
}
272285
}
273286

274287
const changed =
@@ -296,7 +309,7 @@ function publishBundles(bundles, previousBundles, eventStream) {
296309
function createHot(compiler, userOptions) {
297310
const options = userOptions === true ? {} : userOptions;
298311
const path = options.path || HOT_DEFAULT_PATH;
299-
const heartbeat = options.heartbeat || HOT_DEFAULT_HEARTBEAT;
312+
const heartbeat = options.heartbeat ?? HOT_DEFAULT_HEARTBEAT;
300313
const { statsOptions } = options;
301314
const logger = compiler.getInfrastructureLogger("webpack-dev-middleware");
302315

src/options.json

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,23 +197,16 @@
197197
"heartbeat": {
198198
"description": "Heartbeat interval (in milliseconds) used to keep the SSE connection alive.",
199199
"type": "number",
200-
"minimum": 0
200+
"minimum": 1
201201
},
202202
"progress": {
203203
"description": "Publish compilation progress events to the clients.",
204204
"type": "boolean"
205205
},
206206
"statsOptions": {
207207
"description": "Webpack stats options used when serializing compilation results.",
208-
"anyOf": [
209-
{
210-
"type": "boolean"
211-
},
212-
{
213-
"type": "object",
214-
"additionalProperties": true
215-
}
216-
]
208+
"type": "object",
209+
"additionalProperties": true
217210
}
218211
}
219212
}

test/__snapshots__/validation-options.test.js.snap.webpack5

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,13 @@ exports[`validation should throw an error on the "headers" option with "true" va
7777

7878
exports[`validation should throw an error on the "hot" option with "{"heartbeat":-1}" value 1`] = `
7979
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
80-
- options.hot.heartbeat should be >= 0.
80+
- options.hot.heartbeat should be >= 1.
81+
-> Heartbeat interval (in milliseconds) used to keep the SSE connection alive."
82+
`;
83+
84+
exports[`validation should throw an error on the "hot" option with "{"heartbeat":0}" value 1`] = `
85+
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
86+
- options.hot.heartbeat should be >= 1.
8187
-> Heartbeat interval (in milliseconds) used to keep the SSE connection alive."
8288
`;
8389

@@ -87,6 +93,20 @@ exports[`validation should throw an error on the "hot" option with "{"path":""}"
8793
-> The path the SSE endpoint is served at."
8894
`;
8995

96+
exports[`validation should throw an error on the "hot" option with "{"statsOptions":"errors-only"}" value 1`] = `
97+
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
98+
- options.hot.statsOptions should be an object:
99+
object { … }
100+
-> Webpack stats options used when serializing compilation results."
101+
`;
102+
103+
exports[`validation should throw an error on the "hot" option with "{"statsOptions":true}" value 1`] = `
104+
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
105+
- options.hot.statsOptions should be an object:
106+
object { … }
107+
-> Webpack stats options used when serializing compilation results."
108+
`;
109+
90110
exports[`validation should throw an error on the "hot" option with "{"unknown":true}" value 1`] = `
91111
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
92112
- options.hot has an unknown property 'unknown'. These properties are valid:

test/hot.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,33 @@ describe("createHot", () => {
437437
hot.close();
438438
});
439439

440+
it("pairs bundles sharing a name by occurrence so unchanged ones publish sync", () => {
441+
const compiler = makeFakeCompiler();
442+
const hot = createHot(compiler, {});
443+
const { writes } = attachClient({ handler: hot.handle });
444+
445+
// Webpack does not forbid two configs with the same name. Pairing by name
446+
// alone would compare the second "app" against the first one's hash and
447+
// report it as built on every rebuild.
448+
const multiStats = {
449+
stats: [
450+
makeFakeStats({ name: "app", hash: "a1" }),
451+
makeFakeStats({ name: "app", hash: "b1" }),
452+
],
453+
};
454+
455+
compiler.emitDone(multiStats);
456+
writes.length = 0;
457+
458+
compiler.emitInvalid();
459+
compiler.emitDone(multiStats);
460+
461+
expect(writes.filter((w) => w.includes('"action":"sync"'))).toHaveLength(2);
462+
expect(writes.some((w) => w.includes('"action":"built"'))).toBe(false);
463+
464+
hot.close();
465+
});
466+
440467
it("publishes built when the bundle's hash changed", () => {
441468
const compiler = makeFakeCompiler();
442469
const hot = createHot(compiler, {});

test/validation-options.test.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,21 @@ describe("validation", () => {
9292
{},
9393
{ path: "/__hmr" },
9494
{ heartbeat: 1000 },
95-
{ statsOptions: true },
9695
{ statsOptions: { all: false } },
9796
],
98-
failure: ["foo", 0, { path: "" }, { heartbeat: -1 }, { unknown: true }],
97+
failure: [
98+
"foo",
99+
0,
100+
{ path: "" },
101+
{ heartbeat: -1 },
102+
// 0 would silently fall back to the default interval — reject it.
103+
{ heartbeat: 0 },
104+
{ unknown: true },
105+
// Presets and booleans cannot be merged over the middleware's base
106+
// stats options — only the object form is accepted.
107+
{ statsOptions: "errors-only" },
108+
{ statsOptions: true },
109+
],
99110
},
100111
};
101112

types/hot.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ declare const HOT_DEFAULT_HEARTBEAT: number;
5151
/** @typedef {import("webpack").StatsError} StatsError */
5252
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
5353
/** @typedef {import("./index.js").ServerResponse} ServerResponse */
54-
/** @typedef {NonNullable<import("webpack").Configuration["stats"]>} StatsOptions */
54+
/** @typedef {import("webpack").StatsOptions} StatsOptions */
5555
/**
5656
* @typedef {object} HotOptions
5757
* @property {string=} path the path the SSE endpoint is served at
@@ -154,7 +154,7 @@ type StatsCompilation = import("webpack").StatsCompilation;
154154
type StatsError = import("webpack").StatsError;
155155
type IncomingMessage = import("./index.js").IncomingMessage;
156156
type ServerResponse = import("./index.js").ServerResponse;
157-
type StatsOptions = NonNullable<import("webpack").Configuration["stats"]>;
157+
type StatsOptions = import("webpack").StatsOptions;
158158
type HotOptions = {
159159
/**
160160
* the path the SSE endpoint is served at

0 commit comments

Comments
 (0)