diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts index ca0e86f987ff..fd865ee80b56 100644 --- a/packages/angular/build/src/utils/watcher.ts +++ b/packages/angular/build/src/utils/watcher.ts @@ -8,7 +8,6 @@ import type * as ParcelWatcher from '@parcel/watcher'; import type * as Chokidar from 'chokidar'; -import { once } from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; import picomatch from 'picomatch'; @@ -1086,92 +1085,94 @@ async function createParcelWatcher( return buildWatcher; } -/** - * Manages selective node_modules watching for Chokidar. - * - * During Chokidar's initial scan, the allowed packages set is empty, causing Chokidar to prune the entire - * node_modules directory immediately and avoid blocking the 'ready' event for minutes. - * Packages are dynamically allowed and watched at the package root level as files are added via `watcher.add`. - * Tracking files per package allows reference-counting so that package directories are unwatched when no - * longer needed. - */ -class ChokidarNodeModulesManager { - private readonly allowedPackages = new Map>(); - - constructor(private readonly isCaseSensitive: boolean) {} - - registerPackage( - posixPath: string, - lookupKey: string, - ): { isPackage: boolean; newPkgDir?: string } { - const packageDir = extractNodeModulesPackageDir(posixPath); - if (!packageDir) { - return { isPackage: false }; - } - - const packageKey = toLookupKey(packageDir, this.isCaseSensitive); - let watchedInPkg = this.allowedPackages.get(packageKey); - if (!watchedInPkg) { - watchedInPkg = new Set(); - this.allowedPackages.set(packageKey, watchedInPkg); - watchedInPkg.add(lookupKey); - - return { isPackage: true, newPkgDir: packageDir }; - } - - watchedInPkg.add(lookupKey); +function createChokidarIgnored( + ignoredPatterns: string[] | undefined, + isCaseSensitive: boolean, + watchedFiles: ReadonlySet, +): Chokidar.Matcher[] { + // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher). + // If left in place, picomatch would unconditionally ignore all node_modules files and prevent + // watching explicitly added files inside node_modules. + const customIgnored = + ignoredPatterns + ?.filter((pattern) => { + const base = toPosixPathNormalized(pattern).replace(/\/\*\*$/, ''); - return { isPackage: true }; - } + return base !== NODE_MODULES && !base.endsWith(`/${NODE_MODULES}`); + }) + .map((pattern) => { + if (/[*?[\]{}()]/.test(pattern)) { + const isMatch = picomatch(pattern, { dot: true }); - removePackageFile( - posixPath: string, - lookupKey: string, - ): { isPackage: boolean; unwatchPkgDir?: string } { - const packageDir = extractNodeModulesPackageDir(posixPath); - if (!packageDir) { - return { isPackage: false }; - } + return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); + } - const packageKey = toLookupKey(packageDir, this.isCaseSensitive); - const watchedInPkg = this.allowedPackages.get(packageKey); - if (watchedInPkg) { - watchedInPkg.delete(lookupKey); - if (watchedInPkg.size === 0) { - this.allowedPackages.delete(packageKey); + return { path: toPosixPathNormalized(pattern), recursive: true }; + }) ?? []; - return { isPackage: true, unwatchPkgDir: packageDir }; + // When watching a directory (e.g. projectRoot via NG_BUILD_WATCH_ROOT), prune `node_modules` + // directories unless explicitly added to `watchedFiles`. Explicitly added files inside + // `node_modules` do not end with `/node_modules` and remain watchable. + return [ + ...customIgnored, + (filePath: string) => { + if (!filePath.includes(NODE_MODULES)) { + return false; } - } - - return { isPackage: true }; - } - - isIgnored(filePath: string): boolean { - // Fast path: avoid expensive normalization for non-node_modules files - if (!filePath.includes(NODE_MODULES)) { - return false; - } - const posixPath = toPosixPathNormalized(filePath); - const lookupKey = toLookupKey(posixPath, this.isCaseSensitive); + const posixPath = toPosixPathNormalized(filePath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); - if (!`${lookupKey}/`.includes(NODE_MODULES_SEGMENT)) { - return false; - } + return ( + (lookupKey === NODE_MODULES || lookupKey.endsWith(`/${NODE_MODULES}`)) && + !watchedFiles.has(lookupKey) + ); + }, + ]; +} - if (this.allowedPackages.has(lookupKey)) { - return false; - } +interface ChokidarBaselineEntry { + posixPath: string; + mtimeMs?: number; + size?: number; +} - for (const pkg of this.allowedPackages.keys()) { - if (isPathInside(lookupKey, pkg)) { - return false; +// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by +// `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after +// `watcher.add()` is called, Chokidar may capture the post-modification state as its initial +// baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the +// change. Re-statting after a short delay catches any modifications or recreations that occurred +// during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired. +async function checkChokidarBaselines( + baselineStats: ReadonlyMap, + watchedFiles: ReadonlySet, + fileStats: ReadonlyMap, + watcher: Chokidar.FSWatcher, + handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void, +): Promise { + await Promise.all( + Array.from(baselineStats, async ([lookupKey, baseline]) => { + if (!watchedFiles.has(lookupKey)) { + return; + } + const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null); + if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) { + return; } - } - return true; - } + if (baseline.mtimeMs === undefined || baseline.size === undefined) { + const recorded = fileStats.get(lookupKey); + if (!recorded) { + watcher.add(baseline.posixPath); + handleEvent('added', path.normalize(baseline.posixPath)); + } else if (currentStat.mtimeMs !== recorded.mtimeMs || currentStat.size !== recorded.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + } else if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + }), + ); } async function createChokidarWatcher( @@ -1181,43 +1182,19 @@ async function createChokidarWatcher( const chokidar = chokidarModule ?? (await import('chokidar')); const watchedFiles = new Set(); const fileStats = new Map(); + const pendingTimers = new Set>(); - let queue: WatcherQueue; - if (options?.polling) { - const pollingInterval = options.interval ?? 100; - const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); - const maxWaitMs = Math.max(500, debounceMs * 3); - queue = new WatcherQueue(debounceMs, maxWaitMs); - } else { - queue = new WatcherQueue(); - } + const pollingInterval = options?.interval ?? 100; + const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); + const queue = options?.polling + ? new WatcherQueue(debounceMs, Math.max(500, debounceMs * 3)) + : new WatcherQueue(); const rootDir = options?.cwd ?? process.cwd(); const isCaseSensitive = isFileSystemCaseSensitive(rootDir); - const rootDirPosix = toPosixPathNormalized(rootDir); - const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); - - const nodeModulesManager = new ChokidarNodeModulesManager(isCaseSensitive); + const ignored = createChokidarIgnored(options?.ignored, isCaseSensitive, watchedFiles); - // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher). - // If left in place, picomatch would unconditionally ignore all node_modules files and override - // our dynamic package allowlist above. - const customIgnored = - options?.ignored - ?.filter((pattern) => !pattern.includes(NODE_MODULES)) - .map((pattern) => { - if (/[*?[\]{}()]/.test(pattern)) { - const isMatch = picomatch(pattern, { dot: true }); - - return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); - } - - return { path: toPosixPathNormalized(pattern), recursive: true }; - }) ?? []; - - const ignored = [...customIgnored, (filePath: string) => nodeModulesManager.isIgnored(filePath)]; - - const watcher = chokidar.watch(rootDir, { + const watcher = chokidar.watch([], { ignoreInitial: true, ignored, followSymlinks: options?.followSymlinks, @@ -1225,13 +1202,10 @@ async function createChokidarWatcher( interval: options?.interval, }); - const initTime = Date.now(); + // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process. + watcher.on('error', () => {}); - // Wait for the watcher to complete its initial filesystem scan before returning. - // With `ignoreInitial: true`, any file visited during the initial scan is treated as the initial baseline - // and will not emit 'add' or 'change' events. Awaiting 'ready' ensures that rapid file modifications - // made right after watcher setup (e.g. in rebuild tests) are not swallowed as initial files. - await once(watcher, 'ready'); + const initTime = Date.now(); const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { const posixPath = toPosixPathNormalized(rawPath); @@ -1248,14 +1222,10 @@ async function createChokidarWatcher( } const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); - if (!stat) { - return; - } - // Ignore historical events from before watcher initialization, but allow a 1000 ms window // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. - if (stat.mtimeMs < initTime - 1000) { + if (!stat || stat.mtimeMs < initTime - 1000) { return; } @@ -1273,11 +1243,55 @@ async function createChokidarWatcher( queue.addChange(type, rawPath); }; + const scheduleBaselineCheck = (baselineStats: ReadonlyMap) => { + if (baselineStats.size === 0) { + return; + } + + const timer = setTimeout(() => { + pendingTimers.delete(timer); + void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent); + }, 50); + timer.unref(); + pendingTimers.add(timer); + }; + watcher.on('add', (path) => handleEvent('added', path)); watcher.on('change', (path) => handleEvent('modified', path)); - watcher.on('unlink', (path) => handleEvent('removed', path)); + watcher.on('unlink', (rawPath) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } - const buildWatcher: BuildWatcher = { + // When Chokidar processes a file removal, it closes the underlying file watcher unless only a + // single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path + // ensures Chokidar continues watching either the recreated file (if replaced within the atomic + // window) or its parent directory for future recreation. + if (watchedFiles.has(lookupKey)) { + fileStats.delete(lookupKey); + watcher.add(posixPath); + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + handleEvent('modified', rawPath); + scheduleBaselineCheck( + new Map([[lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }]]), + ); + + return; + } + + handleEvent('removed', rawPath); + scheduleBaselineCheck(new Map([[lookupKey, { posixPath }]])); + + return; + } + + handleEvent('removed', rawPath); + }); + + return { [Symbol.asyncIterator]() { return this; }, @@ -1304,28 +1318,23 @@ async function createChokidarWatcher( add(paths) { const targets = typeof paths === 'string' ? [paths] : paths; const newPaths: string[] = []; + const baselineStats = new Map(); + for (const p of targets) { const posixPath = toPosixPathNormalized(p); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!watchedFiles.has(lookupKey)) { watchedFiles.add(lookupKey); - - // For files inside node_modules, register their package directory. Only newly encountered - // package directories need to be added to Chokidar. - const { isPackage, newPkgDir } = nodeModulesManager.registerPackage(posixPath, lookupKey); - if (newPkgDir) { - newPaths.push(newPkgDir); - } else if ( - !isPackage && - !isPathInside(lookupKey, rootDirLookupKey) && - lookupKey !== rootDirLookupKey - ) { - newPaths.push(posixPath); + newPaths.push(posixPath); + const stat = fs.statSync(posixPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + baselineStats.set(lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }); } } } if (newPaths.length > 0) { watcher.add(newPaths); + scheduleBaselineCheck(baselineStats); } }, @@ -1338,21 +1347,7 @@ async function createChokidarWatcher( if (watchedFiles.has(lookupKey)) { watchedFiles.delete(lookupKey); fileStats.delete(lookupKey); - - // When the last watched file in a package is removed, unwatch the package directory. - const { isPackage, unwatchPkgDir } = nodeModulesManager.removePackageFile( - posixPath, - lookupKey, - ); - if (unwatchPkgDir) { - removePaths.push(unwatchPkgDir); - } else if ( - !isPackage && - !isPathInside(lookupKey, rootDirLookupKey) && - lookupKey !== rootDirLookupKey - ) { - removePaths.push(posixPath); - } + removePaths.push(posixPath); } } if (removePaths.length > 0) { @@ -1361,13 +1356,15 @@ async function createChokidarWatcher( }, async close() { + for (const timer of pendingTimers) { + clearTimeout(timer); + } + pendingTimers.clear(); try { await watcher.close(); } finally { queue.close(); } }, - }; - - return buildWatcher; + } satisfies BuildWatcher; } diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts index 3703eb9f4ce1..ef7a14d831d2 100644 --- a/packages/angular/build/src/utils/watcher_spec.ts +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -775,7 +775,7 @@ describe('Watcher', () => { } }, 10000); - it('should unwatch linked package directory when all its files are removed via watcher.remove', async () => { + it('should unwatch linked package file when removed via watcher.remove', async () => { const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-remove-npm-link-')); let watcher: BuildWatcher | undefined; @@ -813,7 +813,7 @@ describe('Watcher', () => { // Remove the linked file from the watcher watcher.remove(linkedFile); - // Allow a brief moment for Chokidar to asynchronously complete unwatching the package directory + // Allow a brief moment for Chokidar to asynchronously complete unwatching the file await setTimeout(250); // Modify both the removed linked file and the regular file @@ -876,6 +876,98 @@ describe('Watcher', () => { fs.rmSync(externalRoot, { recursive: true, force: true }); } }, 10000); + + it('should detect file deletion and recreation when watching multiple files with followSymlinks: true', async () => { + const subDirA = path.join(tempDir, 'dir-a'); + const subDirB = path.join(tempDir, 'dir-b'); + fs.mkdirSync(subDirA); + fs.mkdirSync(subDirB); + + const fileA = path.join(subDirA, 'a.ts'); + const fileB = path.join(subDirB, 'b.ts'); + fs.writeFileSync(fileA, 'export const a = 1;'); + fs.writeFileSync(fileB, 'export const b = 1;'); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + watcher.add([fileA, fileB]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete fileA and wait past Chokidar's atomic write window + fs.rmSync(fileA); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + await setTimeout(150); + + // Recreate fileA and verify the watcher still detects it + fs.writeFileSync(fileA, 'export const a = 2;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + + // Modify recreated fileA again to ensure ongoing changes are tracked + await setTimeout(150); + fs.writeFileSync(fileA, 'export const a = 3;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + } finally { + await watcher.close(); + } + }, 10000); + + it('should prune node_modules when watching root directory while watching added package files', async () => { + const nodeModulesDir = path.join(tempDir, 'node_modules'); + const pkgDir = path.join(nodeModulesDir, 'some-pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + + const watchedPkgFile = path.join(pkgDir, 'index.js'); + const unwatchedPkgFile = path.join(pkgDir, 'other.js'); + const rootFile = path.join(tempDir, 'main.ts'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 1;'); + fs.writeFileSync(unwatchedPkgFile, 'export const other = 1;'); + fs.writeFileSync(rootFile, 'export const main = 1;'); + + const pastTime = new Date(Date.now() - 5000); + fs.utimesSync(watchedPkgFile, pastTime, pastTime); + fs.utimesSync(unwatchedPkgFile, pastTime, pastTime); + fs.utimesSync(rootFile, pastTime, pastTime); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + // Watch both the root directory and a specific file in node_modules + watcher.add([tempDir, watchedPkgFile]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Modify unwatched file in node_modules (should be ignored even though tempDir is watched) + // and watched file in node_modules + fs.writeFileSync(unwatchedPkgFile, 'export const other = 2;'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('index.js'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('other.js'))).toBeFalse(); + } finally { + await watcher.close(); + } + }, 10000); }); describe('ParcelExternalManager', () => {