From a929beae16154329c0bb889e4084b3ab7ef236dd Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:29:42 -0400 Subject: [PATCH] refactor(@angular/build): guard closed chokidar operations and remove v5 unlink workaround Chokidar 6 makes `FSWatcher.close()` terminal, throwing synchronously if `watcher.add()` is called once closing has started. Guard `add()`, `remove()`, `scheduleBaselineCheck()`, and `checkChokidarBaselines()` against `watcher.closed`, and clear tracked file state when the watcher is closed. Additionally, replace the deprecated `usePolling` and `interval` Chokidar options with `backend` and `pollingInterval`. Remove the Chokidar 5 `unlink` re-watch workaround and its corresponding baseline check branch, as Chokidar 6 natively awaits recreated paths from their nearest existing ancestor across all watched directories. --- packages/angular/build/src/utils/watcher.ts | 80 ++++++------------- .../angular/build/src/utils/watcher_spec.ts | 8 ++ 2 files changed, 33 insertions(+), 55 deletions(-) diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts index 8004c8ac5846..afab47f662c7 100644 --- a/packages/angular/build/src/utils/watcher.ts +++ b/packages/angular/build/src/utils/watcher.ts @@ -1141,42 +1141,33 @@ function createChokidarIgnored( interface ChokidarBaselineEntry { posixPath: string; - mtimeMs?: number; - size?: number; + mtimeMs: number; + size: number; } // 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. +// `fs.watch` or polling). If a file is modified immediately after `watcher.add()` is called, +// Chokidar may capture the post-modification state as its initial baseline (with +// `ignoreInitial: true`) and miss the change. Re-statting after a short delay catches any +// modifications 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)) { + if (watcher.closed || !watchedFiles.has(lookupKey)) { return; } const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null); - if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) { + if (watcher.closed || !watchedFiles.has(lookupKey) || !currentStat?.isFile()) { return; } - 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) { + if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) { handleEvent('modified', path.normalize(baseline.posixPath)); } }), @@ -1206,8 +1197,8 @@ async function createChokidarWatcher( ignoreInitial: true, ignored, followSymlinks: options?.followSymlinks, - usePolling: !!options?.polling, - interval: options?.interval, + backend: options?.polling ? 'polling' : 'auto', + pollingInterval: options?.interval, }); // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process. @@ -1252,13 +1243,13 @@ async function createChokidarWatcher( }; const scheduleBaselineCheck = (baselineStats: ReadonlyMap) => { - if (baselineStats.size === 0) { + if (baselineStats.size === 0 || watcher.closed) { return; } const timer = setTimeout(() => { pendingTimers.delete(timer); - void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent); + void checkChokidarBaselines(baselineStats, watchedFiles, watcher, handleEvent); }, 50); timer.unref(); pendingTimers.add(timer); @@ -1266,38 +1257,7 @@ async function createChokidarWatcher( watcher.on('add', (path) => handleEvent('added', path)); watcher.on('change', (path) => handleEvent('modified', path)); - watcher.on('unlink', (rawPath) => { - const posixPath = toPosixPathNormalized(rawPath); - const lookupKey = toLookupKey(posixPath, isCaseSensitive); - if (!isPathWatched(lookupKey, watchedFiles)) { - return; - } - - // 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); - }); + watcher.on('unlink', (path) => handleEvent('removed', path)); return { [Symbol.asyncIterator]() { @@ -1324,6 +1284,10 @@ async function createChokidarWatcher( }, add(paths) { + if (watcher.closed) { + return; + } + const targets = typeof paths === 'string' ? [paths] : paths; const newPaths: string[] = []; const baselineStats = new Map(); @@ -1347,6 +1311,10 @@ async function createChokidarWatcher( }, remove(paths) { + if (watcher.closed) { + return; + } + const targets = typeof paths === 'string' ? [paths] : paths; const removePaths: string[] = []; for (const p of targets) { @@ -1368,6 +1336,8 @@ async function createChokidarWatcher( clearTimeout(timer); } pendingTimers.clear(); + watchedFiles.clear(); + fileStats.clear(); try { await watcher.close(); } finally { diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts index ef7a14d831d2..f198c78da203 100644 --- a/packages/angular/build/src/utils/watcher_spec.ts +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -512,6 +512,14 @@ describe('Watcher', () => { expect(result.done).toBeTrue(); }); + it('should not throw if add() or remove() is called after close()', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + await watcher.close(); + + expect(() => watcher.add(path.join(tempDir, 'main.ts'))).not.toThrow(); + expect(() => watcher.remove(path.join(tempDir, 'main.ts'))).not.toThrow(); + }); + it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => { const testFile = path.join(tempDir, 'test.txt'); fs.writeFileSync(testFile, 'initial');