Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-mode-invalidate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": patch
---

Fixed a crash when calling `invalidate()` in plugin mode (`isPlugin = true`). Since the host (webpack-cli, webpack-dev-server, etc.) owns `compiler.watch()`, the middleware now invalidates the host's `watching` instead (each child compiler's one for a `MultiCompiler` on webpack < 5.109), and logs a warning when the compiler is not watching at all.
33 changes: 30 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,36 @@ function wdm(compiler, options = {}, isPlugin = false) {
instance.invalidate = (callback = noop) => {
middleware.ready(filledContext, callback);

// TODO for plugin usage (`isPlugin = true`) `watching` is `undefined` and this throws —
// invalidate the host's `compiler.watching` (each child's one for a `MultiCompiler`) instead
filledContext.watching.invalidate();
if (filledContext.watching) {
filledContext.watching.invalidate();
return;
}

// For plugin usage the host (webpack-cli, webpack-dev-server, etc.) owns
// `compiler.watch()`, so invalidate the host's `watching` instead
const { compiler: contextCompiler } = filledContext;

let invalidated = false;

if (contextCompiler.watching) {
contextCompiler.watching.invalidate();
invalidated = true;
} else if (isMultipleCompiler(contextCompiler)) {
// `MultiCompiler` exposes `watching` only since webpack 5.109, fall back
// to each child compiler's own `watching`
for (const childCompiler of contextCompiler.compilers) {
if (childCompiler.watching) {
childCompiler.watching.invalidate();
invalidated = true;
}
}
}

if (!invalidated) {
filledContext.logger.warn(
"The `invalidate` method was called, but the compiler is not watching, so there is nothing to invalidate.",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing callback on invalidate no-op

Medium Severity

The !invalidated guard warns but never invokes the callback, unlike close(). Because middleware.ready already ran, a provided callback can stay queued forever when state is false, so callers waiting on invalidate(callback) hang. This violates the plugin-mode guard pattern that requires warning and completing the callback on no-op paths.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Plugin mode guards must log warnings, not silently no-op

Reviewed by Cursor Bugbot for commit c801900. Configure here.

};

instance.close = (callback = noop) => {
Expand Down
2 changes: 2 additions & 0 deletions test/__snapshots__/pluginMode.test.js.snap.webpack5
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
exports[`plugin mode close method should not close the watching owned by the host: warning 1`] = `"The \`close\` method was called, but there is no own \`watching\` instance to close. When using the middleware as a plugin, the host owns watching, so use \`compiler.close()\` instead."`;

exports[`plugin mode close method should not throw and call the callback when the host is not watching: warning 1`] = `"The \`close\` method was called, but there is no own \`watching\` instance to close. When using the middleware as a plugin, the host owns watching, so use \`compiler.close()\` instead."`;

exports[`plugin mode invalidate method should not throw and warn when the host is not watching: warning 1`] = `"The \`invalidate\` method was called, but the compiler is not watching, so there is nothing to invalidate."`;
31 changes: 31 additions & 0 deletions test/pluginMode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ jest.spyOn(globalThis.console, "log").mockImplementation();
// webpack-dev-server, etc.) owns `compiler.watch()`, so the middleware has no
// `watching` of its own
describe("plugin mode", () => {
describe("invalidate method", () => {
it("should invalidate the watching owned by the host", (done) => {
const compiler = getCompiler(webpackConfig);
const watching = compiler.watch({}, () => {});
const instance = middleware(compiler, {}, true);
const invalidateSpy = jest.spyOn(watching, "invalidate");

instance.waitUntilValid(() => {
instance.invalidate();

expect(invalidateSpy).toHaveBeenCalledTimes(1);

watching.close(done);
});
});

it("should not throw and warn when the host is not watching", () => {
const compiler = getCompiler(webpackConfig);
const instance = middleware(compiler, {}, true);
const warnSpy = jest
.spyOn(instance.context.logger, "warn")
.mockImplementation();

expect(() => {
instance.invalidate();
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatchSnapshot("warning");
});
});

describe("close method", () => {
it("should not throw and call the callback when the host is not watching", (done) => {
const compiler = getCompiler(webpackConfig);
Expand Down