From 47d1150aac7c2775908fc756c34c210f6fcdb938 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:55:11 +0000 Subject: [PATCH 1/5] fix: never watch the compiler output path for static changes A static directory frequently contains the compilation's own output, either because `output.path` sits inside it or because `writeToDisk` writes there. Every build then rewrites those files and the static watcher turns a single compilation into a full page reload, which the middleware had already delivered. Where a plugin copies that output back into the copied directory, the same watch reports every generation of the copy and the reloads never stop: measured on a watch with no source edits, 191 reloads in 15s, one per rebuild, from `public/build/build/build/build/main.js` and deeper. Exclude each compiler's output path from the static watcher, as Vite does for `build.outDir`. Paths named explicitly through `watchFiles` are left alone, since those are watched because the user asked for them. --- .../fix-static-watch-ignores-output-path.md | 5 + lib/Server.js | 40 ++++- test/ports-map.js | 1 + test/server/static-watch-output-path.test.js | 152 ++++++++++++++++++ types/lib/Server.d.ts | 11 ++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-static-watch-ignores-output-path.md create mode 100644 test/server/static-watch-output-path.test.js diff --git a/.changeset/fix-static-watch-ignores-output-path.md b/.changeset/fix-static-watch-ignores-output-path.md new file mode 100644 index 0000000000..f618bc7d5b --- /dev/null +++ b/.changeset/fix-static-watch-ignores-output-path.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Stop watching the compiler's `output.path` for static file changes, so a build that writes into a static directory no longer reloads the page on its own output. diff --git a/lib/Server.js b/lib/Server.js index 382de1914a..ec5e0d69c9 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1962,14 +1962,52 @@ class Server { const watchFiles = /** @type {NormalizedStatic[]} */ (this.options.static); if (watchFiles.length > 0) { + const ignoreOutputPath = this.getOutputPathMatcher(); + for (const item of watchFiles) { if (item.watch) { - await this.watchFiles(item.directory, item.watch); + await this.watchFiles(item.directory, { + ...item.watch, + ignored: [ + ...(typeof item.watch.ignored === "undefined" + ? [] + : Array.isArray(item.watch.ignored) + ? item.watch.ignored + : [item.watch.ignored]), + ignoreOutputPath, + ], + }); } } } } + /** + * A static directory often contains the compiler's own output, either because + * `output.path` sits inside it or because `writeToDisk` puts it there. Every + * build then rewrites those files, and watching them turns one compilation + * into a reload — or, when a plugin copies them back in, into an endless one. + * The compilation already reaches the client through the middleware, so the + * output directory is never worth watching. + * @private + * @returns {(targetPath: string) => boolean} true when a path is the output directory or inside it + */ + getOutputPathMatcher() { + const compilers = + /** @type {MultiCompiler} */ + (this.compiler).compilers || [this.compiler]; + const outputPaths = compilers + .map((compiler) => compiler.outputPath) + .filter(Boolean); + + return (targetPath) => + outputPaths.some( + (outputPath) => + targetPath === outputPath || + targetPath.startsWith(outputPath + path.sep), + ); + } + /** * @private * @returns {Promise} diff --git a/test/ports-map.js b/test/ports-map.js index d414a52d80..a89aafb8bb 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -81,6 +81,7 @@ const listOfTests = { "api-plugin": 1, "api-plugin-multi": 2, "harness-server-cleanup": 1, + "static-watch-output-path": 1, }; let startPort = 8089; diff --git a/test/server/static-watch-output-path.test.js b/test/server/static-watch-output-path.test.js new file mode 100644 index 0000000000..7c65b4e985 --- /dev/null +++ b/test/server/static-watch-output-path.test.js @@ -0,0 +1,152 @@ +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { expect } from "expect"; +import fs from "graceful-fs"; +import webpack from "webpack"; +import Server from "../../lib/Server.js"; +import portsMap from "../ports-map.js"; + +const port = portsMap["static-watch-output-path"]; + +// Long enough that a watcher which does report the write has reported it: the +// control below rewrites a file beside the output directory and is seen well +// inside this window, so an empty list after it means the write was ignored +// rather than merely slow. +const SETTLE_MS = 3000; + +const settle = () => + new Promise((resolve) => { + setTimeout(resolve, SETTLE_MS); + }); + +// chokidar suppresses events until its initial scan finishes, so a write sent +// before that is simply lost — the watcher has to be known-ready before the +// absence of a reload means anything. +const waitForWatchers = (watchers, timeout = 10000) => + new Promise((resolve, reject) => { + const started = Date.now(); + const check = () => { + if ( + watchers.length > 0 && + watchers.every( + (watcher) => Object.keys(watcher.getWatched()).length > 0, + ) + ) { + resolve(); + return; + } + + if (Date.now() - started > timeout) { + reject(new Error("timed out waiting for the static watchers")); + return; + } + + setTimeout(check, 50); + }; + + check(); + }); + +const waitForReload = (reloads, file, timeout = 10000) => + new Promise((resolve, reject) => { + const started = Date.now(); + const check = () => { + if (reloads.includes(file)) { + resolve(); + return; + } + + if (Date.now() - started > timeout) { + reject(new Error(`timed out waiting for a reload of ${file}`)); + return; + } + + setTimeout(check, 50); + }; + + check(); + }); + +describe("static watching and output.path", () => { + let tempDirectory; + let outputPath; + let insideOutput; + let outsideOutput; + let compiler; + let server; + let reloads; + + before(async () => { + tempDirectory = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "wds-static-output-"), + ); + outputPath = path.join(tempDirectory, "build"); + insideOutput = path.join(outputPath, "emitted.txt"); + outsideOutput = path.join(tempDirectory, "asset.txt"); + + await fsPromises.mkdir(outputPath, { recursive: true }); + // both files must already exist: chokidar reports a new file as `add`, + // and only a rewrite of a known file as the `change` that reloads + await fsPromises.writeFile(insideOutput, "emitted"); + await fsPromises.writeFile(outsideOutput, "asset"); + await fsPromises.writeFile( + path.join(tempDirectory, "entry.js"), + "module.exports = 1;", + ); + + compiler = webpack({ + mode: "development", + context: tempDirectory, + entry: "./entry.js", + output: { path: outputPath }, + infrastructureLogging: { level: "none" }, + stats: "none", + }); + + server = new Server( + { + static: { directory: tempDirectory, watch: true }, + port, + }, + compiler, + ); + + reloads = []; + + const sendMessage = server.sendMessage.bind(server); + + server.sendMessage = (clients, type, data) => { + if (type === "static-changed") { + reloads.push(String(data)); + } + + return sendMessage(clients, type, data); + }; + + await server.start(); + await waitForWatchers(server.staticWatchers); + }); + + after(async () => { + await server.stop(); + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + it("should not reload when a file inside output.path is rewritten", async () => { + // the compilation already reaches the client through the middleware, so a + // reload here only fires because the build wrote its own output + await fsPromises.writeFile(insideOutput, "emitted again"); + await settle(); + + expect(reloads).toHaveLength(0); + }); + + it("should still reload for the rest of the static directory", async () => { + await fsPromises.writeFile(outsideOutput, "asset again"); + await waitForReload(reloads, outsideOutput); + + expect(reloads).toStrictEqual([outsideOutput]); + }); +}); diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index 16c4696521..b413a721f6 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1636,6 +1636,17 @@ declare class Server< * @returns {Promise} */ private setupWatchStaticFiles; + /** + * A static directory often contains the compiler's own output, either because + * `output.path` sits inside it or because `writeToDisk` puts it there. Every + * build then rewrites those files, and watching them turns one compilation + * into a reload — or, when a plugin copies them back in, into an endless one. + * The compilation already reaches the client through the middleware, so the + * output directory is never worth watching. + * @private + * @returns {(targetPath: string) => boolean} true when a path is the output directory or inside it + */ + private getOutputPathMatcher; /** * @private * @returns {Promise} From b862d2ce5577760a046567ab9f1a5a81df90418a Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:05:53 +0000 Subject: [PATCH 2/5] fix: match output paths at a filesystem root, and prove the watcher is live A root output path already ends in the separator, so appending a second one produced a prefix like `//` that matched nothing below it and every descendant stayed watched. The test established readiness from `getWatched()`, which fills in during chokidar's initial scan rather than at the end of it, so a write could still be dropped and the absence of a reload proved nothing. Rewrite the control file until the watcher reports it instead: the first reload is evidence the scan is over. Reverting lib/Server.js to its base version now fails both assertions. --- lib/Server.js | 14 ++++-- test/server/static-watch-output-path.test.js | 52 +++++++++----------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index ec5e0d69c9..f748ff408c 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2000,11 +2000,17 @@ class Server { .map((compiler) => compiler.outputPath) .filter(Boolean); + // an output path already ending in the separator is a filesystem root, and + // appending a second one would match nothing below it + const prefixes = outputPaths.map((outputPath) => + outputPath.endsWith(path.sep) ? outputPath : outputPath + path.sep, + ); + return (targetPath) => - outputPaths.some( - (outputPath) => - targetPath === outputPath || - targetPath.startsWith(outputPath + path.sep), + outputPaths.some((outputPath, index) => + targetPath === outputPath + ? true + : targetPath.startsWith(prefixes[index]), ); } diff --git a/test/server/static-watch-output-path.test.js b/test/server/static-watch-output-path.test.js index 7c65b4e985..60e0fce2d8 100644 --- a/test/server/static-watch-output-path.test.js +++ b/test/server/static-watch-output-path.test.js @@ -21,33 +21,22 @@ const settle = () => setTimeout(resolve, SETTLE_MS); }); -// chokidar suppresses events until its initial scan finishes, so a write sent -// before that is simply lost — the watcher has to be known-ready before the -// absence of a reload means anything. -const waitForWatchers = (watchers, timeout = 10000) => - new Promise((resolve, reject) => { - const started = Date.now(); - const check = () => { - if ( - watchers.length > 0 && - watchers.every( - (watcher) => Object.keys(watcher.getWatched()).length > 0, - ) - ) { - resolve(); - return; - } - - if (Date.now() - started > timeout) { - reject(new Error("timed out waiting for the static watchers")); - return; - } - - setTimeout(check, 50); - }; - - check(); - }); +// Rewrites `file` until the watcher reports it, so the caller knows the initial +// scan is over and later writes cannot be silently dropped. +const waitUntilWatching = async (reloads, file, timeout = 20000) => { + const started = Date.now(); + + while (!reloads.includes(file)) { + if (Date.now() - started > timeout) { + throw new Error(`the static watcher never reported ${file}`); + } + + await fsPromises.writeFile(file, `warm-up ${Date.now()}`); + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + } +}; const waitForReload = (reloads, file, timeout = 10000) => new Promise((resolve, reject) => { @@ -126,7 +115,14 @@ describe("static watching and output.path", () => { }; await server.start(); - await waitForWatchers(server.staticWatchers); + + // chokidar drops events raised before its initial scan finishes, so a write + // sent too early is simply lost and the absence of a reload would prove + // nothing. Rewriting the control file until the watcher answers establishes + // that it is live, which neither `getWatched()` nor a late `ready` listener + // can: the first fills in during the scan, the second has already fired. + await waitUntilWatching(reloads, outsideOutput); + reloads.length = 0; }); after(async () => { From 7284abafed4e1dfac9e745fd9c0d0b038d3a82fa Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:12:32 +0000 Subject: [PATCH 3/5] test: assert the output directory is never watched, not just never reported Waiting for the root control file to report a change proves the watcher is live, but not that chokidar has discovered the nested file inside the output directory, so a write could still be lost and the zero-reload assertion could pass over a broken predicate. Assert instead on what the watcher decided to watch, which no longer depends on event timing at all, and rewrite the ignored file throughout the window rather than once so no single lost write can hide a regression. Reverting lib/Server.js to its base version fails all three assertions. --- test/server/static-watch-output-path.test.js | 57 +++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/test/server/static-watch-output-path.test.js b/test/server/static-watch-output-path.test.js index 60e0fce2d8..4e434d4085 100644 --- a/test/server/static-watch-output-path.test.js +++ b/test/server/static-watch-output-path.test.js @@ -10,19 +10,22 @@ import portsMap from "../ports-map.js"; const port = portsMap["static-watch-output-path"]; -// Long enough that a watcher which does report the write has reported it: the -// control below rewrites a file beside the output directory and is seen well -// inside this window, so an empty list after it means the write was ignored -// rather than merely slow. -const SETTLE_MS = 3000; - -const settle = () => +// The ignored file is rewritten this many times, this far apart, so that the +// window spans several seconds. A single write could land while chokidar is +// still discovering the nested directory and be lost; a stream of them cannot +// all be. +const REWRITES = 12; +const REWRITE_INTERVAL_MS = 250; + +const sleep = (ms) => new Promise((resolve) => { - setTimeout(resolve, SETTLE_MS); + setTimeout(resolve, ms); }); -// Rewrites `file` until the watcher reports it, so the caller knows the initial -// scan is over and later writes cannot be silently dropped. +// Rewrites `file` until the watcher reports it, which establishes that the +// watcher is live. It does not establish that every nested path has been +// discovered, so it is only the starting gun — the assertions below do not +// rest on it alone. const waitUntilWatching = async (reloads, file, timeout = 20000) => { const started = Date.now(); @@ -32,9 +35,7 @@ const waitUntilWatching = async (reloads, file, timeout = 20000) => { } await fsPromises.writeFile(file, `warm-up ${Date.now()}`); - await new Promise((resolve) => { - setTimeout(resolve, 250); - }); + await sleep(REWRITE_INTERVAL_MS); } }; @@ -117,10 +118,9 @@ describe("static watching and output.path", () => { await server.start(); // chokidar drops events raised before its initial scan finishes, so a write - // sent too early is simply lost and the absence of a reload would prove - // nothing. Rewriting the control file until the watcher answers establishes - // that it is live, which neither `getWatched()` nor a late `ready` listener - // can: the first fills in during the scan, the second has already fired. + // sent too early is simply lost. `ready` cannot be awaited from here — the + // watcher is created inside `start()` and may already have emitted it — so + // the control file is rewritten until the watcher answers. await waitUntilWatching(reloads, outsideOutput); reloads.length = 0; }); @@ -130,11 +130,28 @@ describe("static watching and output.path", () => { fs.rmSync(tempDirectory, { recursive: true, force: true }); }); + it("should not watch the output directory at all", () => { + // the strongest form of the assertion, and the only one that does not + // depend on event timing: chokidar lists what it decided to watch, so a + // predicate that failed to exclude the output directory shows up here even + // if no write ever raced with the scan + const watched = server.staticWatchers.flatMap((watcher) => + Object.keys(watcher.getWatched()), + ); + + expect(watched).toContain(tempDirectory); + expect(watched).not.toContain(outputPath); + }); + it("should not reload when a file inside output.path is rewritten", async () => { // the compilation already reaches the client through the middleware, so a - // reload here only fires because the build wrote its own output - await fsPromises.writeFile(insideOutput, "emitted again"); - await settle(); + // reload here only fires because the build wrote its own output. Rewriting + // throughout the window rather than once means a single write lost to the + // initial scan cannot hide a broken predicate. + for (let index = 0; index < REWRITES; index++) { + await fsPromises.writeFile(insideOutput, `emitted ${index}`); + await sleep(REWRITE_INTERVAL_MS); + } expect(reloads).toHaveLength(0); }); From 3b08f151c8352a119ad0a2b13829a037057b6803 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:28:15 +0000 Subject: [PATCH 4/5] fix: only exclude an output path nested inside the watched directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matching a filesystem-root output path correctly turned out to be worse than matching it not at all. `output.path` defaults to `/` under an in-memory filesystem and is routinely left there — test/fixtures/client-config does exactly that — and `/` contains every static directory there is, so the watcher stopped reporting anything. CI caught it: "should work and log static changes" waited 120s for a reload that no longer came. Exclude an output path only where it is strictly inside the directory being watched, which is the case the change is for: a build emitting into a folder that is also served statically. An output path equal to or containing that folder is left alone, because the user pointed static at it deliberately. The matcher is now built per watched directory rather than once for the server, and the new regression test fails against the previous version. --- lib/Server.js | 55 +++++++++++++------- test/ports-map.js | 2 +- test/server/static-watch-output-path.test.js | 48 ++++++++++++++++- types/lib/Server.d.ts | 16 ++++-- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index f748ff408c..307704810d 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1962,10 +1962,10 @@ class Server { const watchFiles = /** @type {NormalizedStatic[]} */ (this.options.static); if (watchFiles.length > 0) { - const ignoreOutputPath = this.getOutputPathMatcher(); - for (const item of watchFiles) { if (item.watch) { + const ignoreOutputPath = this.getOutputPathMatcher(item.directory); + await this.watchFiles(item.directory, { ...item.watch, ignored: [ @@ -1974,7 +1974,7 @@ class Server { : Array.isArray(item.watch.ignored) ? item.watch.ignored : [item.watch.ignored]), - ignoreOutputPath, + ...(ignoreOutputPath ? [ignoreOutputPath] : []), ], }); } @@ -1983,34 +1983,49 @@ class Server { } /** - * A static directory often contains the compiler's own output, either because - * `output.path` sits inside it or because `writeToDisk` puts it there. Every + * A static directory often contains the compiler's own output, because + * `output.path` sits inside it and `writeToDisk` puts the build there. Every * build then rewrites those files, and watching them turns one compilation * into a reload — or, when a plugin copies them back in, into an endless one. - * The compilation already reaches the client through the middleware, so the - * output directory is never worth watching. + * The compilation already reaches the client through the middleware, so an + * output directory nested in a watched one is never worth watching. + * + * Only a strictly nested output path is excluded. `output.path` defaults to + * `/` under an in-memory filesystem and is routinely left there, and that + * path contains every static directory there is: treating it as output would + * silently stop watching all of them. * @private - * @returns {(targetPath: string) => boolean} true when a path is the output directory or inside it + * @param {string} directory the static directory being watched + * @returns {((targetPath: string) => boolean) | undefined} a matcher for paths inside a nested output directory, or nothing to exclude */ - getOutputPathMatcher() { + getOutputPathMatcher(directory) { const compilers = /** @type {MultiCompiler} */ (this.compiler).compilers || [this.compiler]; - const outputPaths = compilers + // a path already ending in the separator is a filesystem root, and + // appending a second one would match nothing below it + const asPrefix = (/** @type {string} */ aPath) => + aPath.endsWith(path.sep) ? aPath : aPath + path.sep; + const directoryPrefix = asPrefix(directory); + const prefixes = compilers .map((compiler) => compiler.outputPath) - .filter(Boolean); + .filter( + (outputPath) => + outputPath && + outputPath !== directory && + outputPath.startsWith(directoryPrefix), + ) + .map((outputPath) => asPrefix(outputPath)); - // an output path already ending in the separator is a filesystem root, and - // appending a second one would match nothing below it - const prefixes = outputPaths.map((outputPath) => - outputPath.endsWith(path.sep) ? outputPath : outputPath + path.sep, - ); + if (prefixes.length === 0) { + return undefined; + } return (targetPath) => - outputPaths.some((outputPath, index) => - targetPath === outputPath - ? true - : targetPath.startsWith(prefixes[index]), + prefixes.some( + (prefix) => + targetPath === prefix.slice(0, -path.sep.length) || + targetPath.startsWith(prefix), ); } diff --git a/test/ports-map.js b/test/ports-map.js index a89aafb8bb..2c3c035069 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -81,7 +81,7 @@ const listOfTests = { "api-plugin": 1, "api-plugin-multi": 2, "harness-server-cleanup": 1, - "static-watch-output-path": 1, + "static-watch-output-path": 2, }; let startPort = 8089; diff --git a/test/server/static-watch-output-path.test.js b/test/server/static-watch-output-path.test.js index 4e434d4085..e03882368a 100644 --- a/test/server/static-watch-output-path.test.js +++ b/test/server/static-watch-output-path.test.js @@ -8,7 +8,7 @@ import webpack from "webpack"; import Server from "../../lib/Server.js"; import portsMap from "../ports-map.js"; -const port = portsMap["static-watch-output-path"]; +const [port, rootOutputPort] = portsMap["static-watch-output-path"]; // The ignored file is rewritten this many times, this far apart, so that the // window spans several seconds. A single write could land while chokidar is @@ -156,6 +156,52 @@ describe("static watching and output.path", () => { expect(reloads).toHaveLength(0); }); + it("should keep watching when output.path contains the static directory", async () => { + // `output.path` defaults to `/` under an in-memory filesystem and is + // routinely left there, and every static directory is inside it. Treating + // that as output would stop watching everything. + const { root } = path.parse(tempDirectory); + const rootOutputCompiler = webpack({ + mode: "development", + context: tempDirectory, + entry: "./entry.js", + output: { path: root }, + infrastructureLogging: { level: "none" }, + stats: "none", + }); + const rootOutputServer = new Server( + { + static: { directory: tempDirectory, watch: true }, + port: rootOutputPort, + }, + rootOutputCompiler, + ); + + /** @type {string[]} */ + const rootOutputReloads = []; + const sendMessage = rootOutputServer.sendMessage.bind(rootOutputServer); + + rootOutputServer.sendMessage = (clients, type, data) => { + if (type === "static-changed") { + rootOutputReloads.push(String(data)); + } + + return sendMessage(clients, type, data); + }; + + await rootOutputServer.start(); + + try { + await waitUntilWatching(rootOutputReloads, outsideOutput); + + expect(rootOutputReloads).toContain(outsideOutput); + } finally { + await rootOutputServer.stop(); + // the first server watches the same directory, so it saw those writes too + reloads.length = 0; + } + }); + it("should still reload for the rest of the static directory", async () => { await fsPromises.writeFile(outsideOutput, "asset again"); await waitForReload(reloads, outsideOutput); diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index b413a721f6..36353cf953 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1637,14 +1637,20 @@ declare class Server< */ private setupWatchStaticFiles; /** - * A static directory often contains the compiler's own output, either because - * `output.path` sits inside it or because `writeToDisk` puts it there. Every + * A static directory often contains the compiler's own output, because + * `output.path` sits inside it and `writeToDisk` puts the build there. Every * build then rewrites those files, and watching them turns one compilation * into a reload — or, when a plugin copies them back in, into an endless one. - * The compilation already reaches the client through the middleware, so the - * output directory is never worth watching. + * The compilation already reaches the client through the middleware, so an + * output directory nested in a watched one is never worth watching. + * + * Only a strictly nested output path is excluded. `output.path` defaults to + * `/` under an in-memory filesystem and is routinely left there, and that + * path contains every static directory there is: treating it as output would + * silently stop watching all of them. * @private - * @returns {(targetPath: string) => boolean} true when a path is the output directory or inside it + * @param {string} directory the static directory being watched + * @returns {((targetPath: string) => boolean) | undefined} a matcher for paths inside a nested output directory, or nothing to exclude */ private getOutputPathMatcher; /** From 6d37f788082e9c69254f572c8867c03988279edf Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:36:57 +0000 Subject: [PATCH 5/5] fix: resolve paths before deciding an output path is nested `static.directory` is taken from the options as given, so it can be relative, while `outputPath` and the paths the watcher reports are absolute. Comparing them unresolved made the containment check false every time, and the exclusion quietly matched nothing: with a relative static directory the output directory was still watched. Resolve the static directory, each output path and the target, and decide containment with `path.relative` so a `..` segment cannot read as nested. Reverting lib/Server.js fails the new assertion. --- lib/Server.js | 50 ++++++++++------- test/ports-map.js | 2 +- test/server/static-watch-output-path.test.js | 59 +++++++++++++++++++- 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 307704810d..555f84e944 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2002,31 +2002,43 @@ class Server { const compilers = /** @type {MultiCompiler} */ (this.compiler).compilers || [this.compiler]; - // a path already ending in the separator is a filesystem root, and - // appending a second one would match nothing below it - const asPrefix = (/** @type {string} */ aPath) => - aPath.endsWith(path.sep) ? aPath : aPath + path.sep; - const directoryPrefix = asPrefix(directory); - const prefixes = compilers + // `static.directory` is taken as given and may be relative, while + // `outputPath` and the paths the watcher reports are absolute, so + // everything is resolved before being compared + const resolvedDirectory = path.resolve(directory); + /** + * @param {string} parent the directory to test against + * @param {string} child the path that may sit below it + * @returns {boolean} true when `child` is strictly inside `parent` + */ + const isInside = (parent, child) => { + const relative = path.relative(parent, child); + + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); + }; + const outputPaths = compilers .map((compiler) => compiler.outputPath) - .filter( - (outputPath) => - outputPath && - outputPath !== directory && - outputPath.startsWith(directoryPrefix), - ) - .map((outputPath) => asPrefix(outputPath)); + .filter(Boolean) + .map((outputPath) => path.resolve(outputPath)) + .filter((outputPath) => isInside(resolvedDirectory, outputPath)); - if (prefixes.length === 0) { + if (outputPaths.length === 0) { return undefined; } - return (targetPath) => - prefixes.some( - (prefix) => - targetPath === prefix.slice(0, -path.sep.length) || - targetPath.startsWith(prefix), + return (targetPath) => { + const resolvedTarget = path.resolve(targetPath); + + return outputPaths.some( + (outputPath) => + resolvedTarget === outputPath || isInside(outputPath, resolvedTarget), ); + }; } /** diff --git a/test/ports-map.js b/test/ports-map.js index 2c3c035069..9f2b1076ed 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -81,7 +81,7 @@ const listOfTests = { "api-plugin": 1, "api-plugin-multi": 2, "harness-server-cleanup": 1, - "static-watch-output-path": 2, + "static-watch-output-path": 3, }; let startPort = 8089; diff --git a/test/server/static-watch-output-path.test.js b/test/server/static-watch-output-path.test.js index e03882368a..bb8f433b37 100644 --- a/test/server/static-watch-output-path.test.js +++ b/test/server/static-watch-output-path.test.js @@ -8,7 +8,8 @@ import webpack from "webpack"; import Server from "../../lib/Server.js"; import portsMap from "../ports-map.js"; -const [port, rootOutputPort] = portsMap["static-watch-output-path"]; +const [port, rootOutputPort, relativeDirectoryPort] = + portsMap["static-watch-output-path"]; // The ignored file is rewritten this many times, this far apart, so that the // window spans several seconds. A single write could land while chokidar is @@ -39,6 +40,29 @@ const waitUntilWatching = async (reloads, file, timeout = 20000) => { } }; +// The watched set fills in as chokidar scans and is empty right after `start()`, +// so it is polled until it has something and then given a moment to finish. +// Paths are resolved because a relative `static.directory` is reported as given. +const watchedDirectories = async (server, timeout = 20000) => { + const collect = () => + server.staticWatchers + .flatMap((watcher) => Object.keys(watcher.getWatched())) + .map((watchedPath) => path.resolve(watchedPath)); + const started = Date.now(); + + while (collect().length === 0) { + if (Date.now() - started > timeout) { + throw new Error("the static watcher never reported a watched directory"); + } + + await sleep(REWRITE_INTERVAL_MS); + } + + await sleep(2000); + + return collect(); +}; + const waitForReload = (reloads, file, timeout = 10000) => new Promise((resolve, reject) => { const started = Date.now(); @@ -202,6 +226,39 @@ describe("static watching and output.path", () => { } }); + it("should exclude the output path when the static directory is relative", async () => { + // `static.directory` is taken as given, so it can be relative while + // `outputPath` and the watcher's own paths are absolute. Comparing them + // unresolved makes the exclusion quietly match nothing. + const relativeDirectory = path.relative(process.cwd(), tempDirectory); + const relativeCompiler = webpack({ + mode: "development", + context: tempDirectory, + entry: "./entry.js", + output: { path: outputPath }, + infrastructureLogging: { level: "none" }, + stats: "none", + }); + const relativeServer = new Server( + { + static: { directory: relativeDirectory, watch: true }, + port: relativeDirectoryPort, + }, + relativeCompiler, + ); + + await relativeServer.start(); + + try { + const watched = await watchedDirectories(relativeServer); + + expect(watched).toContain(tempDirectory); + expect(watched).not.toContain(outputPath); + } finally { + await relativeServer.stop(); + } + }); + it("should still reload for the rest of the static directory", async () => { await fsPromises.writeFile(outsideOutput, "asset again"); await waitForReload(reloads, outsideOutput);