Skip to content

Commit 29d46b7

Browse files
committed
fix(hot): publish sync instead of built for unchanged bundles (#2357)
* fix(hot): publish sync instead of built for unchanged bundles With a MultiCompiler, rebuilding one child re-emits done for every child. Clients of the unchanged bundles then try to fetch a hot-update manifest that was never emitted (404 / "Cannot find update", or an unwanted full reload with reload=true). Compare each bundle's hash against the previous build and announce unchanged bundles as `sync` instead of `built`. The first build still publishes `built`, and new clients are still caught up with `sync` — but no longer while a rebuild is in progress. Ref webpack/webpack-hot-middleware#312 * fixup!
1 parent 1f258b7 commit 29d46b7

4 files changed

Lines changed: 238 additions & 37 deletions

File tree

src/hot.js

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,14 @@ function formatErrors(errors) {
165165
/**
166166
* @param {Stats} stats stats
167167
* @param {StatsOptions} statsOptions stats options
168-
* @returns {StatsCompilation} json stats with compilation reference attached
168+
* @returns {StatsCompilation} json stats with the compilation name resolved
169169
*/
170170
function normalizeStats(stats, statsOptions) {
171171
const statsJson = stats.toJson(statsOptions);
172172

173-
if (stats.compilation) {
174-
statsJson.compilation = stats.compilation;
173+
// Resolved here so stored bundles do not retain Compilation objects.
174+
if (!statsJson.name && stats.compilation) {
175+
statsJson.name = stats.compilation.name || "";
175176
}
176177

177178
return statsJson;
@@ -194,12 +195,11 @@ function extractBundles(stats) {
194195
}
195196

196197
/**
197-
* @param {string} action action
198198
* @param {Stats | MultiStats} statsResult stats result
199-
* @param {EventStream} eventStream event stream
200199
* @param {StatsOptions | undefined} statsOptions stats options
200+
* @returns {StatsCompilation[]} normalized per-bundle stats
201201
*/
202-
function publishStats(action, statsResult, eventStream, statsOptions) {
202+
function toBundles(statsResult, statsOptions) {
203203
const resultStatsOptions = {
204204
all: false,
205205
hash: true,
@@ -209,29 +209,36 @@ function publishStats(action, statsResult, eventStream, statsOptions) {
209209
...(statsOptions && typeof statsOptions === "object" ? statsOptions : {}),
210210
};
211211

212-
/** @type {StatsCompilation[]} */
213-
let bundles;
214-
215212
// Multi-compiler stats have stats for each child compiler.
216213
if ("stats" in statsResult) {
217-
bundles = statsResult.stats.flatMap((stats) =>
214+
return statsResult.stats.flatMap((stats) =>
218215
extractBundles(normalizeStats(stats, resultStatsOptions)),
219216
);
220-
} else {
221-
bundles = extractBundles(normalizeStats(statsResult, resultStatsOptions));
222217
}
223218

224-
for (const stats of bundles) {
225-
let name = stats.name || "";
219+
return extractBundles(normalizeStats(statsResult, resultStatsOptions));
220+
}
226221

227-
// Fallback to compilation name when there is a single bundle.
228-
if (!name && stats.compilation) {
229-
name = stats.compilation.name || "";
230-
}
222+
/**
223+
* Publish one event per bundle. Bundles whose hash did not change are
224+
* published as `sync`, so their clients do not fetch a hot-update manifest
225+
* that was never emitted.
226+
* @param {StatsCompilation[]} bundles bundles from the current build
227+
* @param {StatsCompilation[] | null} previousBundles bundles from the previous build (null on the first build, which publishes everything as `built`)
228+
* @param {EventStream} eventStream event stream
229+
*/
230+
function publishBundles(bundles, previousBundles, eventStream) {
231+
for (const [index, stats] of bundles.entries()) {
232+
const name = stats.name || "";
233+
234+
const changed =
235+
previousBundles === null ||
236+
!previousBundles[index] ||
237+
previousBundles[index].hash !== stats.hash;
231238

232239
eventStream.publish({
233240
name,
234-
action,
241+
action: changed ? "built" : "sync",
235242
time: stats.time,
236243
hash: stats.hash,
237244
warnings: formatErrors(stats.warnings || []),
@@ -263,8 +270,10 @@ function createHot(compiler, userOptions) {
263270
let eventStream = createEventStream(heartbeat, logger);
264271
logger.log(`Hot module replacement enabled, serving events at "${path}"`);
265272

266-
/** @type {Stats | MultiStats | null} */
267-
let latestStats = null;
273+
// `latestBundles` survives rebuilds so hashes can be compared per build.
274+
/** @type {StatsCompilation[] | null} */
275+
let latestBundles = null;
276+
let valid = false;
268277
let closed = false;
269278
let lastProgressPercent = -1;
270279

@@ -293,7 +302,7 @@ function createHot(compiler, userOptions) {
293302
const onInvalid = (fileName) => {
294303
if (closed) return;
295304

296-
latestStats = null;
305+
valid = false;
297306
lastProgressPercent = -1;
298307

299308
/** @type {{ action: string, file?: string }} */
@@ -312,8 +321,11 @@ function createHot(compiler, userOptions) {
312321
const onDone = (statsResult) => {
313322
if (closed) return;
314323

315-
latestStats = statsResult;
316-
publishStats("built", latestStats, eventStream, statsOptions);
324+
const bundles = toBundles(statsResult, statsOptions);
325+
326+
publishBundles(bundles, latestBundles, eventStream);
327+
latestBundles = bundles;
328+
valid = true;
317329
};
318330

319331
compiler.hooks.invalid.tap(PLUGIN_NAME, onInvalid);
@@ -326,8 +338,9 @@ function createHot(compiler, userOptions) {
326338

327339
eventStream.handler(req, res);
328340

329-
if (latestStats) {
330-
publishStats("sync", latestStats, eventStream, statsOptions);
341+
// Catch the new client up; self-comparison publishes everything as `sync`.
342+
if (valid && latestBundles) {
343+
publishBundles(latestBundles, latestBundles, eventStream);
331344
}
332345
},
333346
publish(payload) {
@@ -354,4 +367,5 @@ module.exports.createEventStream = createEventStream;
354367
module.exports.createHot = createHot;
355368
module.exports.formatErrors = formatErrors;
356369
module.exports.pathMatch = pathMatch;
357-
module.exports.publishStats = publishStats;
370+
module.exports.publishBundles = publishBundles;
371+
module.exports.toBundles = toBundles;

test/hot.test.js

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function makeFakeCompiler(logger = noopLogger) {
3737
}
3838

3939
/**
40-
* Build a minimal Stats-like object that satisfies `publishStats`.
40+
* Build a minimal Stats-like object that satisfies `toBundles`.
4141
* @param {EXPECTED_OBJECT=} overrides field overrides applied on top of the defaults
4242
* @returns {EXPECTED_OBJECT} fake stats
4343
*/
@@ -347,6 +347,100 @@ describe("createHot", () => {
347347
hot.close();
348348
});
349349

350+
it("publishes sync instead of built when a bundle's hash did not change", () => {
351+
const compiler = makeFakeCompiler();
352+
const hot = createHot(compiler, {});
353+
const { writes } = attachClient({ handler: hot.handle });
354+
355+
compiler.emitDone(makeFakeStats({ hash: "same" }));
356+
writes.length = 0;
357+
358+
// A rebuild that produced the same hash (e.g. another bundle of a
359+
// multi-compiler changed) must not be announced as `built`.
360+
compiler.emitInvalid();
361+
compiler.emitDone(makeFakeStats({ hash: "same" }));
362+
363+
expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(true);
364+
expect(writes.some((w) => w.includes('"action":"built"'))).toBe(false);
365+
366+
hot.close();
367+
});
368+
369+
it("publishes built when the bundle's hash changed", () => {
370+
const compiler = makeFakeCompiler();
371+
const hot = createHot(compiler, {});
372+
const { writes } = attachClient({ handler: hot.handle });
373+
374+
compiler.emitDone(makeFakeStats({ hash: "one" }));
375+
writes.length = 0;
376+
377+
compiler.emitInvalid();
378+
compiler.emitDone(makeFakeStats({ hash: "two" }));
379+
380+
expect(
381+
writes.some(
382+
(w) => w.includes('"action":"built"') && w.includes('"hash":"two"'),
383+
),
384+
).toBe(true);
385+
386+
hot.close();
387+
});
388+
389+
it("publishes built only for the changed bundles of a multi-compiler", () => {
390+
const compiler = makeFakeCompiler();
391+
const hot = createHot(compiler, {});
392+
const { writes } = attachClient({ handler: hot.handle });
393+
394+
compiler.emitDone({
395+
stats: [
396+
makeFakeStats({ name: "app", hash: "app-1" }),
397+
makeFakeStats({ name: "admin", hash: "admin-1" }),
398+
],
399+
});
400+
writes.length = 0;
401+
402+
// Only "admin" rebuilt.
403+
compiler.emitInvalid();
404+
compiler.emitDone({
405+
stats: [
406+
makeFakeStats({ name: "app", hash: "app-1" }),
407+
makeFakeStats({ name: "admin", hash: "admin-2" }),
408+
],
409+
});
410+
411+
expect(
412+
writes.some(
413+
(w) => w.includes('"name":"app"') && w.includes('"action":"sync"'),
414+
),
415+
).toBe(true);
416+
expect(
417+
writes.some(
418+
(w) => w.includes('"name":"admin"') && w.includes('"action":"built"'),
419+
),
420+
).toBe(true);
421+
expect(
422+
writes.some(
423+
(w) => w.includes('"name":"app"') && w.includes('"action":"built"'),
424+
),
425+
).toBe(false);
426+
427+
hot.close();
428+
});
429+
430+
it("does not sync new clients while a rebuild is in progress", () => {
431+
const compiler = makeFakeCompiler();
432+
const hot = createHot(compiler, {});
433+
434+
compiler.emitDone(makeFakeStats());
435+
compiler.emitInvalid();
436+
437+
const { writes } = attachClient({ handler: hot.handle });
438+
439+
expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(false);
440+
441+
hot.close();
442+
});
443+
350444
it("sends a sync payload to a client that connects after a build", () => {
351445
const compiler = makeFakeCompiler();
352446
const hot = createHot(compiler, {});

test/middleware.test.js

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs";
2+
import os from "node:os";
23
import path from "node:path";
34

45
import Hapi from "@hapi/hapi";
@@ -7117,7 +7118,85 @@ describe.each([
71177118
}
71187119
});
71197120

7120-
it("streams building and built events on recompilation", async () => {
7121+
it("publishes built only for the changed bundle of a MultiCompiler", async () => {
7122+
// Real watch scenario: two compilers, only one source file changes.
7123+
7124+
const srcDir = fs.mkdtempSync(
7125+
path.join(fs.realpathSync.native(os.tmpdir()), "wdm-hot-multi-"),
7126+
);
7127+
const appEntry = path.join(srcDir, "app.js");
7128+
const adminEntry = path.join(srcDir, "admin.js");
7129+
7130+
fs.writeFileSync(appEntry, "console.log('app v1');");
7131+
fs.writeFileSync(adminEntry, "console.log('admin v1');");
7132+
7133+
const compiler = getCompiler([
7134+
{
7135+
mode: "development",
7136+
name: "app",
7137+
entry: appEntry,
7138+
output: {
7139+
filename: "bundle.js",
7140+
path: path.resolve(__dirname, "./outputs/hot-multi-app"),
7141+
},
7142+
},
7143+
{
7144+
mode: "development",
7145+
name: "admin",
7146+
entry: adminEntry,
7147+
output: {
7148+
filename: "bundle.js",
7149+
path: path.resolve(__dirname, "./outputs/hot-multi-admin"),
7150+
},
7151+
},
7152+
]);
7153+
7154+
[server, req, instance] = await frameworkFactory(
7155+
name,
7156+
framework,
7157+
compiler,
7158+
{ hot: true },
7159+
);
7160+
7161+
try {
7162+
await waitUntilValid(instance);
7163+
7164+
const pending = readSseEvents(server, "/__webpack_hmr", 3000);
7165+
// Change only the "admin" source once the client is listening.
7166+
setTimeout(() => {
7167+
fs.writeFileSync(adminEntry, "console.log('admin v2');");
7168+
}, 300);
7169+
const events = await pending;
7170+
7171+
const buildingIndex = events.findIndex(
7172+
(event) => event.action === "building",
7173+
);
7174+
7175+
expect(buildingIndex).toBeGreaterThan(-1);
7176+
7177+
const afterRebuild = events.slice(buildingIndex + 1);
7178+
7179+
expect(
7180+
afterRebuild.some(
7181+
(event) => event.name === "admin" && event.action === "built",
7182+
),
7183+
).toBe(true);
7184+
expect(
7185+
afterRebuild.some(
7186+
(event) => event.name === "app" && event.action === "sync",
7187+
),
7188+
).toBe(true);
7189+
expect(
7190+
afterRebuild.some(
7191+
(event) => event.name === "app" && event.action === "built",
7192+
),
7193+
).toBe(false);
7194+
} finally {
7195+
fs.rmSync(srcDir, { recursive: true, force: true });
7196+
}
7197+
});
7198+
7199+
it("streams building and sync events when recompilation does not change the hash", async () => {
71217200
const compiler = getCompiler({ ...webpackConfig, watch: true });
71227201
[server, req, instance] = await frameworkFactory(
71237202
name,
@@ -7135,7 +7214,10 @@ describe.each([
71357214
const actions = events.map((event) => event.action);
71367215

71377216
expect(actions).toContain("building");
7138-
expect(actions).toContain("built");
7217+
expect(
7218+
actions.filter((action) => action === "sync").length,
7219+
).toBeGreaterThanOrEqual(2);
7220+
expect(actions).not.toContain("built");
71397221
});
71407222

71417223
it("broadcasts to every connected client", async () => {

types/hot.d.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ declare namespace createHot {
2323
createHot,
2424
formatErrors,
2525
pathMatch,
26-
publishStats,
26+
publishBundles,
27+
toBundles,
2728
HotInstance,
2829
Compiler,
2930
MultiCompiler,
@@ -98,17 +99,27 @@ declare function formatErrors(errors: (string | StatsError)[]): string[];
9899
*/
99100
declare function pathMatch(url: string | undefined, expected: string): boolean;
100101
/**
101-
* @param {string} action action
102-
* @param {Stats | MultiStats} statsResult stats result
102+
* Publish one event per bundle. Bundles whose hash did not change are
103+
* published as `sync`, so their clients do not fetch a hot-update manifest
104+
* that was never emitted.
105+
* @param {StatsCompilation[]} bundles bundles from the current build
106+
* @param {StatsCompilation[] | null} previousBundles bundles from the previous build (null on the first build, which publishes everything as `built`)
103107
* @param {EventStream} eventStream event stream
108+
*/
109+
declare function publishBundles(
110+
bundles: StatsCompilation[],
111+
previousBundles: StatsCompilation[] | null,
112+
eventStream: EventStream,
113+
): void;
114+
/**
115+
* @param {Stats | MultiStats} statsResult stats result
104116
* @param {StatsOptions | undefined} statsOptions stats options
117+
* @returns {StatsCompilation[]} normalized per-bundle stats
105118
*/
106-
declare function publishStats(
107-
action: string,
119+
declare function toBundles(
108120
statsResult: Stats | MultiStats,
109-
eventStream: EventStream,
110121
statsOptions: StatsOptions | undefined,
111-
): void;
122+
): StatsCompilation[];
112123
type HotInstance = {
113124
/**
114125
* path the SSE endpoint is served at

0 commit comments

Comments
 (0)