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..e8301eff7a 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3417,39 +3417,145 @@ class Server { 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 || process.cwd(); + const absolute = (/** @type {string} */ item) => + toPosix(path.resolve(cwd, item)); + 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 cwd = watchOptions.cwd ? toPosix(watchOptions.cwd) : undefined; + const prefix = magicIndex === -1 ? pattern : pattern.slice(0, magicIndex); + const base = prefix.endsWith("/") + ? prefix.slice(0, -1) + : path.posix.dirname(prefix); - const expand = (/** @type {string} */ item) => { - const posix = toPosix(item); - return isDynamicPattern(posix) - ? globSync(posix, { cwd, absolute: true }).map(toNative) - : item; + return path.resolve(cwd, (base || ".").replaceAll(/\\(.)/g, "$1")); }; + const paths = Array.isArray(watchPath) ? watchPath : [watchPath]; + /** @type {string[]} */ + const roots = []; + /** @type {string[]} */ + const patterns = []; + /** @type {string[]} */ + const ignoredPatterns = []; + /** @type {string[]} */ + const literalPaths = []; + /** @type {import("chokidar").Matcher[]} */ + const ignored = []; + + for (const item of paths) { + const pattern = toPosix(item); + let patternStart = 0; + while ( + pattern[patternStart] === "!" && + pattern[patternStart + 1] !== "(" + ) { + patternStart += 1; + } + const positivePattern = pattern.slice(patternStart); - const resolveGlobs = (/** @type {string | string[]} */ input) => - (Array.isArray(input) ? input : [input]).flatMap((item) => - typeof item === "string" ? expand(item) : item, - ); + if (patternStart % 2 === 1) { + if (isDynamicPattern(positivePattern)) { + ignoredPatterns.push(positivePattern); + } else { + ignored.push(path.resolve(cwd, positivePattern)); + } + } else if (isDynamicPattern(positivePattern)) { + roots.push(getWatchRoot(positivePattern)); + patterns.push(positivePattern); + } else { + const filePath = absolute(positivePattern); + roots.push(path.resolve(cwd, positivePattern)); + literalPaths.push(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))) { + ignoredPatterns.push(toPosix(item)); + } 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, + /** @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, + ), + ), + ); + }; - const watcher = chokidar.watch(resolvedPaths, watchOptions); + refreshGlobMatches(); + + const watcher = chokidar.watch([...new Set(roots)], { + ...watchOptions, + ignored, + }); + let ready = false; + + watcher.on("ready", () => { + ready = true; + }); + const refreshAfterAdd = () => { + if (ready && (patterns.length > 0 || ignoredPatterns.length > 0)) { + refreshGlobMatches(); + } + }; + + watcher.on("add", refreshAfterAdd); + watcher.on("addDir", refreshAfterAdd); // 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",