Skip to content

Commit 37bdfe2

Browse files
committed
fix(hot): name building events and repair the client reconnect lifecycle
The building payload now carries the name of the compilation that invalidated (tapped per child compiler, since the MultiCompiler hook does not say which one fired), so clients can pair it with the built or sync that follows — without it the building indicator registered every build under "" and could never be hidden for named compilations. On the client, the inactivity watchdog is restarted inside init(), so it survives a reconnect instead of dying with the first clearInterval, and the reconnect timeout handle is now stored and cleared by close(), so disconnect() during the reconnect window no longer resurrects an orphaned, uncloseable connection. All three defects were inherited from webpack-hot-middleware.
1 parent e4791ea commit 37bdfe2

4 files changed

Lines changed: 116 additions & 15 deletions

File tree

client-src/index.js

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ function createEventSourceWrapper() {
143143
const listeners = [];
144144
/** @type {ReturnType<typeof setInterval>} */
145145
let timer;
146+
/** @type {ReturnType<typeof setTimeout>} */
147+
let reconnectTimer;
146148

147149
const handleOnline = () => {
148150
log.info("connected");
@@ -161,37 +163,47 @@ function createEventSourceWrapper() {
161163

162164
/**
163165
* Close the connection and stop the activity timer without scheduling a
164-
* reconnection.
166+
* reconnection. A reconnection that is already pending is cancelled too, so
167+
* closing during the reconnect window really is final.
165168
*/
166169
const close = () => {
167170
clearInterval(timer);
171+
clearTimeout(reconnectTimer);
168172
source.close();
169173
};
170174

171175
const handleDisconnect = () => {
172176
close();
173-
setTimeout(init, /** @type {number} */ (options.timeout));
177+
reconnectTimer = setTimeout(init, /** @type {number} */ (options.timeout));
174178
};
175179

176180
/**
177-
* Open the EventSource connection.
181+
* Open the EventSource connection and (re)start the inactivity watchdog —
182+
* `handleDisconnect` stops the watchdog, so a reconnected source has to
183+
* bring its own.
178184
*/
179185
function init() {
180186
source = new window.EventSource(/** @type {string} */ (options.path));
181187
source.addEventListener("open", handleOnline);
182188
source.addEventListener("error", handleDisconnect);
183189
source.addEventListener("message", handleMessage);
190+
191+
lastActivity = Date.now();
192+
clearInterval(timer);
193+
timer = setInterval(
194+
() => {
195+
if (
196+
Date.now() - lastActivity >
197+
/** @type {number} */ (options.timeout)
198+
) {
199+
handleDisconnect();
200+
}
201+
},
202+
/** @type {number} */ (options.timeout) / 2,
203+
);
184204
}
185205

186206
init();
187-
timer = setInterval(
188-
() => {
189-
if (Date.now() - lastActivity > /** @type {number} */ (options.timeout)) {
190-
handleDisconnect();
191-
}
192-
},
193-
/** @type {number} */ (options.timeout) / 2,
194-
);
195207

196208
return {
197209
addMessageListener(fn) {

src/hot.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,16 +322,25 @@ function createHot(compiler, userOptions) {
322322
}).apply(compiler);
323323
}
324324

325-
/** @param {string | null=} fileName file that triggered the rebuild */
326-
const onInvalid = (fileName) => {
325+
/**
326+
* @param {string=} name name of the compilation the hook belongs to
327+
* @returns {(fileName?: string | null) => void} invalid hook handler
328+
*/
329+
const onInvalid = (name) => (fileName) => {
327330
if (closed) return;
328331

329332
valid = false;
330333
lastProgressPercent = -1;
331334

332-
/** @type {{ action: string, file?: string }} */
335+
/** @type {{ action: string, name?: string, file?: string }} */
333336
const payload = { action: "building" };
334337

338+
// Named so clients can pair this event with the `built`/`sync` that
339+
// follows it — the building indicator tracks in-flight builds per name.
340+
if (name) {
341+
payload.name = name;
342+
}
343+
335344
// The invalid hook reports which file changed — forward it so clients
336345
// can show what triggered the rebuild.
337346
if (typeof fileName === "string" && fileName) {
@@ -352,7 +361,13 @@ function createHot(compiler, userOptions) {
352361
valid = true;
353362
};
354363

355-
compiler.hooks.invalid.tap(PLUGIN_NAME, onInvalid);
364+
// Tapped per child compiler rather than on the MultiCompiler hook, which
365+
// does not say which compilation invalidated.
366+
for (const child of "compilers" in compiler
367+
? compiler.compilers
368+
: [compiler]) {
369+
child.hooks.invalid.tap(PLUGIN_NAME, onInvalid(child.name));
370+
}
356371
compiler.hooks.done.tap(PLUGIN_NAME, onDone);
357372

358373
return {

test/client.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,44 @@ describe("client", () => {
10101010
expect(EventSourceStub.instances).toHaveLength(1);
10111011
});
10121012

1013+
it("keeps the inactivity watchdog after a reconnect", () => {
1014+
jest.useFakeTimers({ doNotFake: ["nextTick"] });
1015+
delete globalThis.__wdmEventSourceWrapper;
1016+
EventSourceStub.instances.length = 0;
1017+
loadClient();
1018+
1019+
// First silent stall: the watchdog disconnects and a reconnect opens a
1020+
// second source 20s later.
1021+
jest.advanceTimersByTime(30 * 1000);
1022+
jest.advanceTimersByTime(20 * 1000);
1023+
expect(EventSourceStub.instances).toHaveLength(2);
1024+
1025+
// The reconnected source must be watched too: another silent stall has
1026+
// to close it and schedule a third connection.
1027+
const second = EventSourceStub.lastInstance();
1028+
jest.advanceTimersByTime(30 * 1000);
1029+
expect(second.closed).toBe(true);
1030+
jest.advanceTimersByTime(20 * 1000);
1031+
expect(EventSourceStub.instances).toHaveLength(3);
1032+
});
1033+
1034+
it("disconnect() during the reconnect window cancels the pending reconnect", () => {
1035+
jest.useFakeTimers({ doNotFake: ["nextTick"] });
1036+
delete globalThis.__wdmEventSourceWrapper;
1037+
EventSourceStub.instances.length = 0;
1038+
const freshClient = loadClient();
1039+
1040+
// A connection error schedules a reconnect `timeout` (20s) out.
1041+
const [first] = EventSourceStub.instances;
1042+
first.dispatch("error", {});
1043+
expect(first.closed).toBe(true);
1044+
1045+
// Disconnecting inside that window must cancel it — nothing may reopen.
1046+
freshClient.disconnect();
1047+
jest.advanceTimersByTime(60 * 1000);
1048+
expect(EventSourceStub.instances).toHaveLength(1);
1049+
});
1050+
10131051
it("disconnect() drops the cached wrapper so a new connect starts fresh", () => {
10141052
client.disconnect();
10151053
expect(

test/hot.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,42 @@ describe("createHot", () => {
347347
hot.close();
348348
});
349349

350+
it("includes the compilation name in the building payload", () => {
351+
const compiler = makeFakeCompiler();
352+
compiler.name = "main";
353+
const hot = createHot(compiler, {});
354+
const { writes } = attachClient({ handler: hot.handle });
355+
356+
compiler.emitInvalid();
357+
358+
// The client pairs `building` with the `built`/`sync` that follows by
359+
// name — without it the building indicator can never be hidden.
360+
const building = writes.find((w) => w.includes('"action":"building"'));
361+
expect(building).toContain('"name":"main"');
362+
363+
hot.close();
364+
});
365+
366+
it("names the building payload after the compiler that invalidated in a multi-compiler", () => {
367+
const app = makeFakeCompiler();
368+
app.name = "app";
369+
const widget = makeFakeCompiler();
370+
widget.name = "widget";
371+
const multi = makeFakeCompiler();
372+
multi.compilers = [app, widget];
373+
const hot = createHot(multi, {});
374+
const { writes } = attachClient({ handler: hot.handle });
375+
376+
widget.emitInvalid("/src/widget.js");
377+
378+
const building = writes.filter((w) => w.includes('"action":"building"'));
379+
expect(building).toHaveLength(1);
380+
expect(building[0]).toContain('"name":"widget"');
381+
expect(building[0]).toContain('"file":"/src/widget.js"');
382+
383+
hot.close();
384+
});
385+
350386
it("publishes sync instead of built when a bundle's hash did not change", () => {
351387
const compiler = makeFakeCompiler();
352388
const hot = createHot(compiler, {});

0 commit comments

Comments
 (0)