From 53763d3d7696c6a7f6756b5da6ab8992e1d1994d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 13 Jul 2026 23:16:12 -0500 Subject: [PATCH] feat(hot): include the changed file in the building event The compiler's invalid hook reports which file invalidated the compilation. Forward it as an optional `file` field on the `building` payload and log it in the client, so users can see what triggered a rebuild. Ref webpack/webpack-hot-middleware#173 --- client-src/index.js | 8 ++++++-- src/hot.js | 15 +++++++++++++-- test/client.test.js | 11 +++++++++++ test/helpers/sse.js | 1 + test/hot.test.js | 36 ++++++++++++++++++++++++++++++++++-- types/hot.d.ts | 5 +++++ 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/client-src/index.js b/client-src/index.js index 106ab1f8e..c4b3d799c 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -183,7 +183,7 @@ export function setOptionsAndConnect(overrides) { // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ -/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, modules?: Record, action?: string }} HMRPayload */ +/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, modules?: Record, action?: string, file?: string }} HMRPayload */ /** * @returns {{ @@ -269,7 +269,11 @@ let subscribeAllHandler; function processMessage(obj) { switch (obj.action) { case "building": { - log.info(`bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding`); + log.info( + `bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilding${ + obj.file ? ` (${obj.file} changed)` : "" + }`, + ); break; } case "built": diff --git a/src/hot.js b/src/hot.js index dd3f4090b..91f062408 100644 --- a/src/hot.js +++ b/src/hot.js @@ -21,6 +21,7 @@ /** * @typedef {object} Payload * @property {string} action action + * @property {string=} file file that invalidated the compilation * @property {string=} name name * @property {number=} time time * @property {string=} hash hash @@ -282,12 +283,22 @@ function createHot(compiler, userOptions) { let latestStats = null; let closed = false; - const onInvalid = () => { + /** @param {string | null=} fileName file that triggered the rebuild */ + const onInvalid = (fileName) => { if (closed) return; latestStats = null; - eventStream.publish({ action: "building" }); + /** @type {{ action: string, file?: string }} */ + const payload = { action: "building" }; + + // The invalid hook reports which file changed — forward it so clients + // can show what triggered the rebuild. + if (typeof fileName === "string" && fileName) { + payload.file = fileName; + } + + eventStream.publish(payload); }; /** @param {Stats | MultiStats} statsResult stats result */ diff --git a/test/client.test.js b/test/client.test.js index e310b30b0..5b2e849a7 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -160,6 +160,17 @@ describe("client", () => { expect(processUpdate).toHaveBeenCalledTimes(1); }); + it("logs the changed file on building messages", () => { + EventSourceStub.lastInstance().onmessage( + makeMessage({ action: "building", file: "/src/index.js" }), + ); + expect( + console.info.mock.calls.some(([msg]) => + msg.includes("rebuilding (/src/index.js changed)"), + ), + ).toBe(true); + }); + it("calls subscribeAll handler on default messages", () => { const spy = jest.fn(); client.subscribeAll(spy); diff --git a/test/helpers/sse.js b/test/helpers/sse.js index 23c98883c..d6255b5cc 100644 --- a/test/helpers/sse.js +++ b/test/helpers/sse.js @@ -3,6 +3,7 @@ import http from "node:http"; /** * @typedef {object} SseEvent * @property {string=} action event action (building/built/sync/custom) + * @property {string=} file file that invalidated the compilation * @property {string=} name compilation name * @property {string=} hash compilation hash * @property {number=} time build time in ms diff --git a/test/hot.test.js b/test/hot.test.js index 9fb4f01bd..f2a885d3b 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -28,8 +28,8 @@ function makeFakeCompiler(logger = noopLogger) { done: { tap: (_name, fn) => doneTaps.push(fn) }, }, getInfrastructureLogger: () => logger, - emitInvalid() { - for (const fn of invalidTaps) fn(); + emitInvalid(fileName) { + for (const fn of invalidTaps) fn(fileName); }, emitDone(stats) { for (const fn of doneTaps) fn(stats); @@ -327,6 +327,38 @@ describe("createHot", () => { hot.close(); }); + it("includes the changed file in the building payload", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + const { writes } = attachClient({ handler: hot.handle }); + + compiler.emitInvalid("/src/index.js"); + + expect( + writes.some( + (w) => + w.includes('"action":"building"') && + w.includes('"file":"/src/index.js"'), + ), + ).toBe(true); + + hot.close(); + }); + + it("omits the file field when the invalid hook reports none", () => { + const compiler = makeFakeCompiler(); + const hot = createHot(compiler, {}); + const { writes } = attachClient({ handler: hot.handle }); + + compiler.emitInvalid(); + + const building = writes.find((w) => w.includes('"action":"building"')); + expect(building).toBeDefined(); + expect(building).not.toContain('"file"'); + + hot.close(); + }); + it("sends a sync payload to a client that connects after a build", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); diff --git a/types/hot.d.ts b/types/hot.d.ts index cac94957c..c3852d82f 100644 --- a/types/hot.d.ts +++ b/types/hot.d.ts @@ -63,6 +63,7 @@ declare const HOT_DEFAULT_HEARTBEAT: number; /** * @typedef {object} Payload * @property {string} action action + * @property {string=} file file that invalidated the compilation * @property {string=} name name * @property {number=} time time * @property {string=} hash hash @@ -168,6 +169,10 @@ type Payload = { * action */ action: string; + /** + * file that invalidated the compilation + */ + file?: string | undefined; /** * name */