From 1af428805dc7f1eb9aef19e29a0748a7d83d9690 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:04:05 +0300 Subject: [PATCH 1/7] fix: watch future glob matches without mutating options --- .changeset/watch-future-glob-matches.md | 5 ++ lib/Server.js | 81 +++++++++++++++++-------- package-lock.json | 1 + package.json | 1 + 4 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 .changeset/watch-future-glob-matches.md diff --git a/.changeset/watch-future-glob-matches.md b/.changeset/watch-future-glob-matches.md new file mode 100644 index 0000000000..c0ede41119 --- /dev/null +++ b/.changeset/watch-future-glob-matches.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Keep glob-based file watches active for files created after startup, and match ignored globs against future paths without mutating the supplied watch options. diff --git a/lib/Server.js b/lib/Server.js index 38577856c9..0533754db8 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3412,39 +3412,72 @@ class Server { */ async watchFiles(watchPath, watchOptions = {}) { const { default: chokidar } = await import("chokidar"); - const { globSync, isDynamicPattern } = await import("tinyglobby"); + const { default: picomatch } = await import("picomatch"); + const { isDynamicPattern } = await import("tinyglobby"); const isWin = path.sep === "\\"; const toPosix = (/** @type {string} */ filePath) => isWin ? filePath.split(path.sep).join("/") : filePath; - const toNative = (/** @type {string} */ filePath) => - isWin ? filePath.split("/").join(path.sep) : filePath; - - const cwd = watchOptions.cwd ? toPosix(watchOptions.cwd) : undefined; - - const expand = (/** @type {string} */ item) => { - const posix = toPosix(item); - return isDynamicPattern(posix) - ? globSync(posix, { cwd, absolute: true }).map(toNative) - : item; - }; - - const resolveGlobs = (/** @type {string | string[]} */ input) => - (Array.isArray(input) ? input : [input]).flatMap((item) => - typeof item === "string" ? expand(item) : item, - ); + const cwd = watchOptions.cwd || process.cwd(); + const absolute = (/** @type {string} */ item) => + toPosix(path.resolve(cwd, item)); + const paths = Array.isArray(watchPath) ? watchPath : [watchPath]; + /** @type {string[]} */ + const roots = []; + /** @type {((file: string) => boolean)[]} */ + const matches = []; + /** @type {import("chokidar").Matcher[]} */ + const ignored = []; + let hasGlobs = false; + + for (const item of paths) { + const pattern = toPosix(item); + const scanned = picomatch.scan(pattern, { parts: true, unescape: true }); + + if (scanned.negated) { + const match = picomatch(absolute(pattern.slice(scanned.start))); + ignored.push((file) => match(absolute(file))); + } else if (isDynamicPattern(pattern)) { + hasGlobs = true; + roots.push(path.resolve(cwd, scanned.base || ".")); + matches.push(picomatch(absolute(pattern))); + } else { + const filePath = absolute(item); + roots.push(path.resolve(cwd, item)); + matches.push( + (file) => file === filePath || file.startsWith(`${filePath}/`), + ); + } + } - const resolvedPaths = resolveGlobs(watchPath); + const originalIgnored = watchOptions.ignored; + for (const item of Array.isArray(originalIgnored) + ? originalIgnored + : originalIgnored === undefined + ? [] + : [originalIgnored]) { + if (typeof item === "string" && isDynamicPattern(toPosix(item))) { + const match = picomatch(absolute(item)); + ignored.push((file) => match(absolute(file))); + } else { + ignored.push(item); + } + } - if (typeof watchOptions.ignored === "string") { - watchOptions.ignored = resolveGlobs(watchOptions.ignored); - } else if (Array.isArray(watchOptions.ignored)) { - watchOptions.ignored = watchOptions.ignored.flatMap((item) => - typeof item === "string" ? expand(item) : item, + if (hasGlobs) { + // Watch the glob's parent so future matches are discovered, but do not + // register file watchers for unrelated files. Directories remain traversable. + ignored.push( + (file, stats) => + Boolean(stats?.isFile()) && + !matches.some((match) => match(absolute(file))), ); } - const watcher = chokidar.watch(resolvedPaths, watchOptions); + const watcher = chokidar.watch([...new Set(roots)], { + ...watchOptions, + ignored, + }); // disabling refreshing on changing the content if (this.options.liveReload) { diff --git a/package-lock.json b/package-lock.json index 67f7f2df12..860b65a4c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "launch-editor": "^2.14.1", "open": "^11.0.0", "p-retry": "^8.0.0", + "picomatch": "^4.0.4", "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", "serve-index": "^1.9.2", diff --git a/package.json b/package.json index 38fbb1dd59..c06f690df4 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "launch-editor": "^2.14.1", "open": "^11.0.0", "p-retry": "^8.0.0", + "picomatch": "^4.0.4", "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", "serve-index": "^1.9.2", From 47ecbf554d6e7081309ac0b3815239dc5f95c538 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:03:18 +0300 Subject: [PATCH 2/7] Avoid a direct glob dependency --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index c06f690df4..38fbb1dd59 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "launch-editor": "^2.14.1", "open": "^11.0.0", "p-retry": "^8.0.0", - "picomatch": "^4.0.4", "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", "serve-index": "^1.9.2", From 7b0ca48d5858dedf0f07fa48af848b6ab997c6a1 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:03:21 +0300 Subject: [PATCH 3/7] Avoid a direct glob dependency --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 860b65a4c7..67f7f2df12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,6 @@ "launch-editor": "^2.14.1", "open": "^11.0.0", "p-retry": "^8.0.0", - "picomatch": "^4.0.4", "schema-utils": "^4.3.3", "selfsigned": "^5.5.0", "serve-index": "^1.9.2", From 4e6f8a379cd72597d245b660bac23ccde3462480 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:04:02 +0300 Subject: [PATCH 4/7] Use Node glob matching for watch paths --- lib/Server.js | 61 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 0533754db8..08cde375da 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3412,7 +3412,6 @@ class Server { */ async watchFiles(watchPath, watchOptions = {}) { const { default: chokidar } = await import("chokidar"); - const { default: picomatch } = await import("picomatch"); const { isDynamicPattern } = await import("tinyglobby"); const isWin = path.sep === "\\"; @@ -3421,6 +3420,42 @@ class Server { const cwd = watchOptions.cwd || process.cwd(); const absolute = (/** @type {string} */ item) => toPosix(path.resolve(cwd, item)); + const matchesGlob = ( + /** @type {string} */ file, + /** @type {string} */ pattern, + ) => path.matchesGlob(absolute(file), absolute(pattern)); + const getWatchRoot = (/** @type {string} */ pattern) => { + let escaped = false; + let magicIndex = -1; + + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if ( + "*?[{".includes(character) || + ("!+@".includes(character) && pattern[index + 1] === "(") + ) { + magicIndex = index; + break; + } + } + + const prefix = + magicIndex === -1 ? pattern : pattern.slice(0, magicIndex); + const base = prefix.endsWith("/") + ? prefix.slice(0, -1) + : path.posix.dirname(prefix); + + return path.resolve(cwd, (base || ".").replace(/\\(.)/g, "$1")); + }; const paths = Array.isArray(watchPath) ? watchPath : [watchPath]; /** @type {string[]} */ const roots = []; @@ -3432,18 +3467,21 @@ class Server { for (const item of paths) { const pattern = toPosix(item); - const scanned = picomatch.scan(pattern, { parts: true, unescape: true }); + let patternStart = 0; + while (pattern[patternStart] === "!") { + patternStart += 1; + } + const positivePattern = pattern.slice(patternStart); - if (scanned.negated) { - const match = picomatch(absolute(pattern.slice(scanned.start))); - ignored.push((file) => match(absolute(file))); - } else if (isDynamicPattern(pattern)) { + if (patternStart % 2 === 1) { + ignored.push((file) => matchesGlob(file, positivePattern)); + } else if (isDynamicPattern(positivePattern)) { hasGlobs = true; - roots.push(path.resolve(cwd, scanned.base || ".")); - matches.push(picomatch(absolute(pattern))); + roots.push(getWatchRoot(positivePattern)); + matches.push((file) => matchesGlob(file, positivePattern)); } else { - const filePath = absolute(item); - roots.push(path.resolve(cwd, item)); + const filePath = absolute(positivePattern); + roots.push(path.resolve(cwd, positivePattern)); matches.push( (file) => file === filePath || file.startsWith(`${filePath}/`), ); @@ -3457,8 +3495,7 @@ class Server { ? [] : [originalIgnored]) { if (typeof item === "string" && isDynamicPattern(toPosix(item))) { - const match = picomatch(absolute(item)); - ignored.push((file) => match(absolute(file))); + ignored.push((file) => matchesGlob(file, toPosix(item))); } else { ignored.push(item); } From 8048d2ca9fb4cd2ab0a8efbef7f87e116a002c5b Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:10:38 +0300 Subject: [PATCH 5/7] Reuse tinyglobby for future watch matches --- lib/Server.js | 89 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 08cde375da..b762551025 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3412,7 +3412,7 @@ class Server { */ async watchFiles(watchPath, watchOptions = {}) { const { default: chokidar } = await import("chokidar"); - const { isDynamicPattern } = await import("tinyglobby"); + const { globSync, isDynamicPattern } = await import("tinyglobby"); const isWin = path.sep === "\\"; const toPosix = (/** @type {string} */ filePath) => @@ -3420,10 +3420,6 @@ class Server { const cwd = watchOptions.cwd || process.cwd(); const absolute = (/** @type {string} */ item) => toPosix(path.resolve(cwd, item)); - const matchesGlob = ( - /** @type {string} */ file, - /** @type {string} */ pattern, - ) => path.matchesGlob(absolute(file), absolute(pattern)); const getWatchRoot = (/** @type {string} */ pattern) => { let escaped = false; let magicIndex = -1; @@ -3448,22 +3444,24 @@ class Server { } } - const prefix = - magicIndex === -1 ? pattern : pattern.slice(0, magicIndex); + const prefix = magicIndex === -1 ? pattern : pattern.slice(0, magicIndex); const base = prefix.endsWith("/") ? prefix.slice(0, -1) : path.posix.dirname(prefix); - return path.resolve(cwd, (base || ".").replace(/\\(.)/g, "$1")); + return path.resolve(cwd, (base || ".").replaceAll(/\\(.)/g, "$1")); }; const paths = Array.isArray(watchPath) ? watchPath : [watchPath]; /** @type {string[]} */ const roots = []; - /** @type {((file: string) => boolean)[]} */ - const matches = []; + /** @type {string[]} */ + const patterns = []; + /** @type {string[]} */ + const ignoredPatterns = []; + /** @type {string[]} */ + const literalPaths = []; /** @type {import("chokidar").Matcher[]} */ const ignored = []; - let hasGlobs = false; for (const item of paths) { const pattern = toPosix(item); @@ -3474,17 +3472,18 @@ class Server { const positivePattern = pattern.slice(patternStart); if (patternStart % 2 === 1) { - ignored.push((file) => matchesGlob(file, positivePattern)); + if (isDynamicPattern(positivePattern)) { + ignoredPatterns.push(positivePattern); + } else { + ignored.push(path.resolve(cwd, positivePattern)); + } } else if (isDynamicPattern(positivePattern)) { - hasGlobs = true; roots.push(getWatchRoot(positivePattern)); - matches.push((file) => matchesGlob(file, positivePattern)); + patterns.push(positivePattern); } else { const filePath = absolute(positivePattern); roots.push(path.resolve(cwd, positivePattern)); - matches.push( - (file) => file === filePath || file.startsWith(`${filePath}/`), - ); + literalPaths.push(filePath); } } @@ -3495,31 +3494,67 @@ class Server { ? [] : [originalIgnored]) { if (typeof item === "string" && isDynamicPattern(toPosix(item))) { - ignored.push((file) => matchesGlob(file, toPosix(item))); + ignoredPatterns.push(toPosix(item)); } else { ignored.push(item); } } - if (hasGlobs) { - // Watch the glob's parent so future matches are discovered, but do not - // register file watchers for unrelated files. Directories remain traversable. - ignored.push( - (file, stats) => - Boolean(stats?.isFile()) && - !matches.some((match) => match(absolute(file))), + /** @type {Set} */ + let matchedPaths; + /** @type {Set} */ + let ignoredPaths; + const refreshGlobMatches = () => { + matchedPaths = new Set( + patterns.flatMap((pattern) => + globSync(pattern, { cwd, absolute: true, onlyFiles: false }).map( + absolute, + ), + ), ); - } + ignoredPaths = new Set( + ignoredPatterns.flatMap((pattern) => + globSync(pattern, { cwd, absolute: true, onlyFiles: false }).map( + absolute, + ), + ), + ); + }; + + refreshGlobMatches(); const watcher = chokidar.watch([...new Set(roots)], { ...watchOptions, ignored, }); + let ready = false; + + watcher.on("ready", () => { + ready = true; + }); + watcher.on("add", () => { + if (ready) { + refreshGlobMatches(); + } + }); + watcher.on("addDir", () => { + if (ready) { + refreshGlobMatches(); + } + }); // disabling refreshing on changing the content if (this.options.liveReload) { watcher.on("change", (item) => { - if (this.webSocketServer) { + const file = absolute(item); + const isMatch = + matchedPaths.has(file) || + literalPaths.some( + (literalPath) => + file === literalPath || file.startsWith(`${literalPath}/`), + ); + + if (this.webSocketServer && isMatch && !ignoredPaths.has(file)) { this.sendMessage( this.webSocketServer.clients, "static-changed", From e4f44a22fdaa7b7b54cadf4902b51b95712a652c Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:11:08 +0300 Subject: [PATCH 6/7] Avoid rescanning literal-only watches --- lib/Server.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index b762551025..c45227ff5b 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3532,16 +3532,14 @@ class Server { watcher.on("ready", () => { ready = true; }); - watcher.on("add", () => { - if (ready) { + const refreshAfterAdd = () => { + if (ready && (patterns.length > 0 || ignoredPatterns.length > 0)) { refreshGlobMatches(); } - }); - watcher.on("addDir", () => { - if (ready) { - refreshGlobMatches(); - } - }); + }; + + watcher.on("add", refreshAfterAdd); + watcher.on("addDir", refreshAfterAdd); // disabling refreshing on changing the content if (this.options.liveReload) { From fda6370e294ec0caa0f7d5136b70bf7a2a5c2fb5 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:26:03 +0300 Subject: [PATCH 7/7] Preserve leading extglob patterns --- lib/Server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Server.js b/lib/Server.js index c45227ff5b..e8301eff7a 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3466,7 +3466,10 @@ class Server { for (const item of paths) { const pattern = toPosix(item); let patternStart = 0; - while (pattern[patternStart] === "!") { + while ( + pattern[patternStart] === "!" && + pattern[patternStart + 1] !== "(" + ) { patternStart += 1; } const positivePattern = pattern.slice(patternStart);