Skip to content

Commit 5c05907

Browse files
committed
fix(hot): scope the catch-up sync and pair bundles by name
- A client connecting after a build now receives the catch-up `sync` alone instead of it being broadcast to every connected client, which re-triggered their reporters on each new tab. - `publishBundles` pairs the previous build's bundles by name instead of array index, so a changing set of compilations cannot compare a bundle against a sibling's hash. Unnamed bundles keep the positional pairing. - The client's console dedup cache is cleared per bundle, so a sibling's clean build no longer re-logs another bundle's unchanged problems.
1 parent 1abd3b2 commit 5c05907

7 files changed

Lines changed: 242 additions & 20 deletions

File tree

client-src/globals.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ declare module "ansi-html-community" {
99
}
1010

1111
interface ClientReporter {
12-
cleanProblemsCache(): void;
12+
cleanProblemsCache(name: string): void;
1313
problems(
1414
type: "errors" | "warnings",
1515
obj: { errors: string[]; warnings: string[]; name?: string },

client-src/index.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export function disconnect() {
264264

265265
/**
266266
* @returns {{
267-
* cleanProblemsCache: () => void,
267+
* cleanProblemsCache: (name: string) => void,
268268
* problems: (type: "errors" | "warnings", obj: HMRPayload) => boolean,
269269
* success: (obj?: HMRPayload) => void,
270270
* useCustomOverlay: (customOverlay: EXPECTED_ANY) => void,
@@ -384,8 +384,10 @@ function createReporter() {
384384
};
385385

386386
return {
387-
cleanProblemsCache() {
388-
previousProblems.clear();
387+
cleanProblemsCache(name) {
388+
// Scoped to one bundle so a sibling's unchanged problems do not re-log.
389+
previousProblems.delete(`${name}|errors`);
390+
previousProblems.delete(`${name}|warnings`);
389391
},
390392
problems(type, obj) {
391393
logProblems(type, obj);
@@ -465,11 +467,11 @@ function processMessage(obj) {
465467
reporter.problems("warnings", obj);
466468
}
467469
} else if (reporter) {
468-
reporter.cleanProblemsCache();
470+
reporter.cleanProblemsCache(obj.name || "");
469471
reporter.success(obj);
470472
}
471473
if (shouldApply) {
472-
applyUpdate(obj.hash, options);
474+
applyUpdate(obj.hash, options, obj.name);
473475
}
474476
break;
475477
}

client-src/process-update.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/";
88

99
/** @type {string | undefined} */
1010
let lastHash;
11+
// Name of the compilation this runtime belongs to, locked on the first event
12+
// whose hash matches `__webpack_hash__` (e.g. the `sync` sent on connect).
13+
/** @type {string | undefined} */
14+
let ownName;
1115
/** @type {Record<string, number>} */
1216
const failureStatuses = { abort: 1, fail: 1 };
1317
// Set per applyUpdate() call from the client's `reload` option.
@@ -55,8 +59,21 @@ function upToDate(hash) {
5559
/**
5660
* @param {string} hash latest hash from the SSE payload
5761
* @param {{ reload?: boolean }} options client options
62+
* @param {string=} name compilation name the payload belongs to
5863
*/
59-
export default function applyUpdate(hash, options) {
64+
export default function applyUpdate(hash, options, name) {
65+
// MultiCompiler: a sibling bundle's hash would poison `lastHash` and end
66+
// in "Cannot find update" plus a spurious full reload.
67+
if (name) {
68+
if (ownName === undefined && hash === __webpack_hash__) {
69+
ownName = name;
70+
}
71+
72+
if (ownName !== undefined && name !== ownName) {
73+
return;
74+
}
75+
}
76+
6077
const hot = getHot();
6178

6279
if (!hot) {
@@ -150,6 +167,12 @@ export default function applyUpdate(hash, options) {
150167
.check(false)
151168
.then((updatedModules) => {
152169
if (!updatedModules) {
170+
// A sibling bundle's event that raced the connect-time lock — the
171+
// missing manifest is expected, not a reason to reload.
172+
if (name && ownName !== undefined && name !== ownName) {
173+
return undefined;
174+
}
175+
153176
log.warn("Cannot find update (Full reload needed)");
154177
log.warn("(Probably because of restarting the server)");
155178
performReload();

src/hot.js

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* @typedef {object} EventStream
3636
* @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client
3737
* @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client
38+
* @property {(res: ServerResponse, payload: Payload | { action: string }) => void} publishTo publish a payload to a single client
3839
* @property {() => void} close end every client and stop the heartbeat
3940
*/
4041

@@ -138,6 +139,9 @@ function createEventStream(heartbeat, logger) {
138139
client.write(`data: ${JSON.stringify(payload)}\n\n`);
139140
});
140141
},
142+
publishTo(res, payload) {
143+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
144+
},
141145
};
142146
}
143147

@@ -219,6 +223,22 @@ function toBundles(statsResult, statsOptions) {
219223
return extractBundles(normalizeStats(statsResult, resultStatsOptions));
220224
}
221225

226+
/**
227+
* @param {StatsCompilation} stats normalized per-bundle stats
228+
* @param {"built" | "sync"} action action
229+
* @returns {Payload} SSE payload
230+
*/
231+
function bundlePayload(stats, action) {
232+
return {
233+
name: stats.name || "",
234+
action,
235+
time: stats.time,
236+
hash: stats.hash,
237+
warnings: formatErrors(stats.warnings || []),
238+
errors: formatErrors(stats.errors || []),
239+
};
240+
}
241+
222242
/**
223243
* Publish one event per bundle. Bundles whose hash did not change are
224244
* published as `sync`, so their clients do not fetch a hot-update manifest
@@ -231,19 +251,23 @@ function publishBundles(bundles, previousBundles, eventStream) {
231251
for (const [index, stats] of bundles.entries()) {
232252
const name = stats.name || "";
233253

254+
// Paired by name so a changing set of compilations (children appearing,
255+
// config reloads) cannot compare a bundle against a sibling's hash;
256+
// unnamed bundles fall back to their position.
257+
let previous = null;
258+
259+
if (previousBundles !== null) {
260+
previous = name
261+
? previousBundles.find((bundle) => (bundle.name || "") === name) || null
262+
: previousBundles[index] || null;
263+
}
264+
234265
const changed =
235266
previousBundles === null ||
236-
!previousBundles[index] ||
237-
previousBundles[index].hash !== stats.hash;
238-
239-
eventStream.publish({
240-
name,
241-
action: changed ? "built" : "sync",
242-
time: stats.time,
243-
hash: stats.hash,
244-
warnings: formatErrors(stats.warnings || []),
245-
errors: formatErrors(stats.errors || []),
246-
});
267+
previous === null ||
268+
previous.hash !== stats.hash;
269+
270+
eventStream.publish(bundlePayload(stats, changed ? "built" : "sync"));
247271
}
248272
}
249273

@@ -338,9 +362,11 @@ function createHot(compiler, userOptions) {
338362

339363
eventStream.handler(req, res);
340364

341-
// Catch the new client up; self-comparison publishes everything as `sync`.
365+
// Catch only the new client up, as `sync` events with the last hashes.
342366
if (valid && latestBundles) {
343-
publishBundles(latestBundles, latestBundles, eventStream);
367+
for (const stats of latestBundles) {
368+
eventStream.publishTo(res, bundlePayload(stats, "sync"));
369+
}
344370
}
345371
},
346372
publish(payload) {

test/client.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ describe("client", () => {
147147
expect(processUpdate).toHaveBeenCalledWith(
148148
"1234567890abcdef",
149149
expect.objectContaining({ reload: true }),
150+
undefined,
150151
);
151152
});
152153

@@ -429,6 +430,80 @@ describe("client", () => {
429430
expect(clientOverlay.clear).toHaveBeenCalledTimes(1);
430431
});
431432

433+
it("re-logs the same error text after the bundle's own successful build", () => {
434+
const es = EventSourceStub.lastInstance();
435+
const brokenPayload = {
436+
action: "built",
437+
name: "app",
438+
time: 100,
439+
hash: "app-hash",
440+
errors: ["app broke"],
441+
warnings: [],
442+
};
443+
es.onmessage(makeMessage(brokenPayload));
444+
445+
const appLogs = () =>
446+
console.error.mock.calls.filter((call) =>
447+
call.join(" ").includes("app broke"),
448+
).length;
449+
const before = appLogs();
450+
451+
expect(before).toBeGreaterThan(0);
452+
453+
// The bundle's own success drops its console cache…
454+
es.onmessage(
455+
makeMessage({
456+
action: "built",
457+
name: "app",
458+
time: 100,
459+
hash: "app-hash-2",
460+
errors: [],
461+
warnings: [],
462+
}),
463+
);
464+
// …so breaking again with the exact same text logs again.
465+
es.onmessage(makeMessage({ ...brokenPayload, hash: "app-hash-3" }));
466+
467+
expect(appLogs()).toBe(before * 2);
468+
});
469+
470+
it("does not re-log a bundle's unchanged errors when a sibling succeeds", () => {
471+
const es = EventSourceStub.lastInstance();
472+
const appPayload = {
473+
action: "sync",
474+
name: "app",
475+
time: 100,
476+
hash: "app-hash",
477+
errors: ["app broke"],
478+
warnings: [],
479+
};
480+
es.onmessage(makeMessage(appPayload));
481+
482+
const appLogs = () =>
483+
console.error.mock.calls.filter((call) =>
484+
call.join(" ").includes("app broke"),
485+
).length;
486+
const before = appLogs();
487+
488+
expect(before).toBeGreaterThan(0);
489+
490+
// A clean sibling build clears only its own console cache, so
491+
// re-publishing app's identical errors stays de-duplicated.
492+
es.onmessage(
493+
makeMessage({
494+
action: "built",
495+
name: "admin",
496+
time: 100,
497+
hash: "admin-hash",
498+
errors: [],
499+
warnings: [],
500+
}),
501+
);
502+
es.onmessage(makeMessage(appPayload));
503+
504+
expect(appLogs()).toBe(before);
505+
});
506+
432507
it("shows the union of problems from every broken bundle", () => {
433508
const es = EventSourceStub.lastInstance();
434509
es.onmessage(
@@ -683,6 +758,7 @@ describe("client", () => {
683758
expect(processUpdate).toHaveBeenCalledWith(
684759
"1234567890abcdef",
685760
expect.objectContaining({ reload: false }),
761+
undefined,
686762
);
687763
});
688764
});

test/hot.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,52 @@ describe("createHot", () => {
455455
hot.close();
456456
});
457457

458+
it("does not re-send the catch-up sync to already connected clients", () => {
459+
const compiler = makeFakeCompiler();
460+
const hot = createHot(compiler, {});
461+
const { writes: firstWrites } = attachClient({ handler: hot.handle });
462+
463+
compiler.emitDone(makeFakeStats());
464+
firstWrites.length = 0;
465+
466+
const { writes: secondWrites } = attachClient({ handler: hot.handle });
467+
468+
expect(secondWrites.some((w) => w.includes('"action":"sync"'))).toBe(true);
469+
expect(firstWrites.some((w) => w.includes('"action":"sync"'))).toBe(false);
470+
471+
hot.close();
472+
});
473+
474+
it("pairs bundles by name when the compilation order changes", () => {
475+
const compiler = makeFakeCompiler();
476+
const hot = createHot(compiler, {});
477+
const { writes } = attachClient({ handler: hot.handle });
478+
479+
compiler.emitDone({
480+
stats: [
481+
makeFakeStats({ name: "app", hash: "app-1" }),
482+
makeFakeStats({ name: "admin", hash: "admin-1" }),
483+
],
484+
});
485+
writes.length = 0;
486+
487+
// Same hashes, different order — nothing actually changed.
488+
compiler.emitInvalid();
489+
compiler.emitDone({
490+
stats: [
491+
makeFakeStats({ name: "admin", hash: "admin-1" }),
492+
makeFakeStats({ name: "app", hash: "app-1" }),
493+
],
494+
});
495+
496+
expect(writes.some((w) => w.includes('"action":"built"'))).toBe(false);
497+
expect(
498+
writes.filter((w) => w.includes('"action":"sync"')).length,
499+
).toBeGreaterThanOrEqual(2);
500+
501+
hot.close();
502+
});
503+
458504
it("falls back to compilation.name when stats name is empty", () => {
459505
const compiler = makeFakeCompiler();
460506
const hot = createHot(compiler, {});

test/process-update.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,55 @@ describe("process-update", () => {
9797
expect(reloadPage).not.toHaveBeenCalled();
9898
});
9999

100+
describe("multi-compiler bundles", () => {
101+
it("ignores events from sibling bundles once the own compilation is identified", async () => {
102+
// The `sync` sent on connect carries the bundle's own hash and locks the name.
103+
applyUpdate("current-hash", { reload: true }, "app");
104+
105+
const hot = getHot();
106+
107+
expect(hot.check).not.toHaveBeenCalled();
108+
109+
// A sibling bundle rebuilds with a hash this runtime can never match.
110+
applyUpdate("admin-hash", { reload: true }, "admin");
111+
await flushPromises();
112+
113+
expect(hot.check).not.toHaveBeenCalled();
114+
expect(reloadPage).not.toHaveBeenCalled();
115+
116+
// The own bundle rebuilding still applies the update.
117+
applyUpdate("app-hash-2", { reload: true }, "app");
118+
globalThis.__webpack_hash__ = "app-hash-2";
119+
await flushPromises();
120+
121+
expect(hot.check).toHaveBeenCalledWith(false);
122+
expect(hot.apply).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it("does not reload when a pre-lock sibling check resolves without an update", async () => {
126+
applyUpdate = loadApplyUpdate(makeFakeHot({ checkResult: null }));
127+
globalThis.__webpack_hash__ = "app-hash";
128+
129+
// Connect-time catch-up: the sibling's sync arrives first and starts a
130+
// check against a manifest that was never emitted…
131+
applyUpdate("admin-hash", { reload: true }, "admin");
132+
// …and the own bundle's sync locks the name before that check resolves.
133+
applyUpdate("app-hash", { reload: true }, "app");
134+
await flushPromises();
135+
136+
expect(getHot().check).toHaveBeenCalledTimes(1);
137+
expect(reloadPage).not.toHaveBeenCalled();
138+
});
139+
140+
it("still checks named events while the own compilation is unknown", async () => {
141+
applyUpdate("new-hash", { reload: true }, "admin");
142+
globalThis.__webpack_hash__ = "new-hash";
143+
await flushPromises();
144+
145+
expect(getHot().check).toHaveBeenCalledWith(false);
146+
});
147+
});
148+
100149
it("checks and applies an update when the hash differs", async () => {
101150
applyUpdate("new-hash", { reload: true });
102151
globalThis.__webpack_hash__ = "new-hash";

0 commit comments

Comments
 (0)