diff --git a/.changeset/webpack-runtime-watch-only.md b/.changeset/webpack-runtime-watch-only.md new file mode 100644 index 0000000..59ab772 --- /dev/null +++ b/.changeset/webpack-runtime-watch-only.md @@ -0,0 +1,5 @@ +--- +"agoda-devfeedback-webpack": patch +--- + +Only inject the DevFeedback client runtime module in watch mode (dev server). The browser snippet was previously added to every compilation's initial chunks, including one-off production/CI builds and SSR bundles — it broke html-webpack-plugin template evaluation (`window is not defined` in node:vm) and would crash SSR bundles running in Node. The generated client code also now guards on `typeof window`/`document` as a defensive measure. diff --git a/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.spec.ts b/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.spec.ts index ec5d206..b610eb5 100644 --- a/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.spec.ts +++ b/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.spec.ts @@ -18,6 +18,7 @@ const mockedSendBuildData = sendBuildData as unknown as ReturnType */ const createMockCompiler = () => { return { + watchMode: false, hooks: { watchRun: { tap: vi.fn(), @@ -67,13 +68,27 @@ describe('WebpackBuildStatsPlugin', () => { 'WebpackBuildStatsPlugin', expect.any(Function), ); - // The plugin also uses `compilation.tap(...)` internally + }); + + it('should inject the client runtime module in watch mode', () => { + mockedCompiler.watchMode = true; + plugin.apply(mockedCompiler as unknown as Compiler); + expect(mockedCompiler.hooks.compilation.tap).toHaveBeenCalledWith( 'WebpackBuildStatsPlugin', expect.any(Function), ); }); + it('should not inject the client runtime module for one-off builds', () => { + mockedCompiler.watchMode = false; + plugin.apply(mockedCompiler as unknown as Compiler); + + // One-off builds (production/CI/SSR) must not get the browser runtime — + // it breaks html-webpack-plugin template evaluation and SSR bundles. + expect(mockedCompiler.hooks.compilation.tap).not.toHaveBeenCalled(); + }); + it('should send the correct build stats data on done', async () => { // Arrange const plugin = new WebpackBuildStatsPlugin('my custom identifier'); diff --git a/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.ts b/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.ts index 534ab27..a4f9b71 100644 --- a/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.ts +++ b/packages/webpack-plugin/src/lib/webpack-build-stats-plugin.ts @@ -96,23 +96,31 @@ export class WebpackBuildStatsPlugin { }); /** - * 4) Inject client code as a runtime module + * 4) Inject client code as a runtime module — watch mode only. + * + * The client snippet exists to report HMR / DOM-update events during local + * development, so it only makes sense when webpack is watching (dev server). + * Injecting it into one-off builds (production, CI, SSR) breaks consumers: + * html-webpack-plugin child compilations evaluate the template in node:vm + * where `window` is undefined, and SSR bundles execute the runtime in Node. */ - compiler.hooks.compilation.tap( - 'WebpackBuildStatsPlugin', - (compilation: Compilation) => { - compilation.hooks.afterChunks.tap('WebpackBuildStatsPlugin', (chunks) => { - for (const chunk of chunks) { - if (chunk.canBeInitial()) { - compilation.addRuntimeModule( - chunk, - new DevFeedbackRuntimeModule(() => this.generateClientCode()), - ); + if (compiler.watchMode) { + compiler.hooks.compilation.tap( + 'WebpackBuildStatsPlugin', + (compilation: Compilation) => { + compilation.hooks.afterChunks.tap('WebpackBuildStatsPlugin', (chunks) => { + for (const chunk of chunks) { + if (chunk.canBeInitial()) { + compilation.addRuntimeModule( + chunk, + new DevFeedbackRuntimeModule(() => this.generateClientCode()), + ); + } } - } - }); - }, - ); + }); + }, + ); + } } /** @@ -165,6 +173,11 @@ export class WebpackBuildStatsPlugin { return ` (function(){ + // Defensive: never execute outside a browser (e.g. SSR bundles or + // html-webpack-plugin template evaluation in node:vm). + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } window.__BUILD_START__ = ${buildStartTime}; var ws = new WebSocket('${wsUrl}');