Skip to content

Commit 5194b41

Browse files
committed
fix(@angular/build): prevent chokidar from scanning unimported node_modules in watch mode
When using Chokidar (such as when `preserveSymlinks: true` is configured or polling is active), Chokidar's initial filesystem scan previously walked the entire `workspaceRoot`, including all of `node_modules`. Because `await once(watcher, 'ready')` blocks until the initial scan completes, this caused watch mode in commands like `ng test`, `ng serve`, and `ng build --watch` to hang for minutes before emitting the first build result. This commit introduces selective `node_modules` filtering in Chokidar: - `node_modules` packages corresponding to imported build files (e.g. `npm link` libraries, both unscoped and scoped) are allowed and their directories watched, retaining native symlink traversal. - All other unimported subdirectories in `node_modules` are pruned and ignored during the initial scan and subsequent directory visits. - Packages added dynamically via `watcher.add()` are registered and watched on the fly. Fixes #34162 (cherry picked from commit 91b0947)
1 parent 37cf399 commit 5194b41

2 files changed

Lines changed: 433 additions & 22 deletions

File tree

‎packages/angular/build/src/utils/watcher.ts‎

Lines changed: 163 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export interface WatcherOptions {
4949
cwd?: string;
5050
}
5151

52+
const NODE_MODULES = 'node_modules';
53+
const NODE_MODULES_SEGMENT = `/${NODE_MODULES}/`;
54+
5255
// Watch workspace for package manager changes
5356
const packageWatchFiles = [
5457
// manifest can affect module resolution
@@ -109,7 +112,7 @@ export async function setupWatcher(options: SetupWatcherOptions): Promise<BuildW
109112
// Ignore all node modules directories to avoid excessive file watchers.
110113
// Package changes are handled below by watching manifest and lock files.
111114
// NOTE: this is not enabled when preserveSymlinks is true as this would break `npm link` usages.
112-
ignored.push('**/node_modules/**');
115+
ignored.push(`**/${NODE_MODULES}/**`);
113116
}
114117

115118
const watcher = await createWatcher({
@@ -386,6 +389,36 @@ export function isPathInside(file: string, dir: string): boolean {
386389
return file.startsWith(dirWithSlash);
387390
}
388391

392+
/**
393+
* Extracts the package directory inside node_modules for a given POSIX path.
394+
*
395+
* Input Expectations:
396+
* - `posixPath` must be a normalized POSIX-style path (using forward slashes '/').
397+
*/
398+
export function extractNodeModulesPackageDir(posixPath: string): string | undefined {
399+
const index = posixPath.lastIndexOf(NODE_MODULES_SEGMENT);
400+
if (index === -1) {
401+
return undefined;
402+
}
403+
404+
const nodeModulesPath = posixPath.slice(0, index + NODE_MODULES_SEGMENT.length - 1);
405+
const remainder = posixPath.slice(index + NODE_MODULES_SEGMENT.length);
406+
const segments = remainder.split('/');
407+
if (segments.length === 0 || !segments[0]) {
408+
return undefined;
409+
}
410+
411+
if (segments[0].startsWith('@')) {
412+
if (segments.length < 2 || !segments[1]) {
413+
return undefined;
414+
}
415+
416+
return `${nodeModulesPath}/${segments[0]}/${segments[1]}`;
417+
}
418+
419+
return `${nodeModulesPath}/${segments[0]}`;
420+
}
421+
389422
class ParcelExternalManager {
390423
private readonly extraSubscriptions = new Map<string, ParcelWatcher.AsyncSubscription>();
391424
private readonly pendingSubscriptions = new Map<
@@ -625,6 +658,94 @@ async function createParcelWatcher(
625658
return buildWatcher;
626659
}
627660

661+
/**
662+
* Manages selective node_modules watching for Chokidar.
663+
*
664+
* During Chokidar's initial scan, the allowed packages set is empty, causing Chokidar to prune the entire
665+
* node_modules directory immediately and avoid blocking the 'ready' event for minutes.
666+
* Packages are dynamically allowed and watched at the package root level as files are added via `watcher.add`.
667+
* Tracking files per package allows reference-counting so that package directories are unwatched when no
668+
* longer needed.
669+
*/
670+
class ChokidarNodeModulesManager {
671+
private readonly allowedPackages = new Map<string, Set<string>>();
672+
673+
constructor(private readonly isCaseSensitive: boolean) {}
674+
675+
registerPackage(
676+
posixPath: string,
677+
lookupKey: string,
678+
): { isPackage: boolean; newPkgDir?: string } {
679+
const packageDir = extractNodeModulesPackageDir(posixPath);
680+
if (!packageDir) {
681+
return { isPackage: false };
682+
}
683+
684+
const packageKey = toLookupKey(packageDir, this.isCaseSensitive);
685+
let watchedInPkg = this.allowedPackages.get(packageKey);
686+
if (!watchedInPkg) {
687+
watchedInPkg = new Set();
688+
this.allowedPackages.set(packageKey, watchedInPkg);
689+
watchedInPkg.add(lookupKey);
690+
691+
return { isPackage: true, newPkgDir: packageDir };
692+
}
693+
694+
watchedInPkg.add(lookupKey);
695+
696+
return { isPackage: true };
697+
}
698+
699+
removePackageFile(
700+
posixPath: string,
701+
lookupKey: string,
702+
): { isPackage: boolean; unwatchPkgDir?: string } {
703+
const packageDir = extractNodeModulesPackageDir(posixPath);
704+
if (!packageDir) {
705+
return { isPackage: false };
706+
}
707+
708+
const packageKey = toLookupKey(packageDir, this.isCaseSensitive);
709+
const watchedInPkg = this.allowedPackages.get(packageKey);
710+
if (watchedInPkg) {
711+
watchedInPkg.delete(lookupKey);
712+
if (watchedInPkg.size === 0) {
713+
this.allowedPackages.delete(packageKey);
714+
715+
return { isPackage: true, unwatchPkgDir: packageDir };
716+
}
717+
}
718+
719+
return { isPackage: true };
720+
}
721+
722+
isIgnored(filePath: string): boolean {
723+
// Fast path: avoid expensive normalization for non-node_modules files
724+
if (!filePath.includes(NODE_MODULES)) {
725+
return false;
726+
}
727+
728+
const posixPath = toPosixPathNormalized(filePath);
729+
const lookupKey = toLookupKey(posixPath, this.isCaseSensitive);
730+
731+
if (!`${lookupKey}/`.includes(NODE_MODULES_SEGMENT)) {
732+
return false;
733+
}
734+
735+
if (this.allowedPackages.has(lookupKey)) {
736+
return false;
737+
}
738+
739+
for (const pkg of this.allowedPackages.keys()) {
740+
if (isPathInside(lookupKey, pkg)) {
741+
return false;
742+
}
743+
}
744+
745+
return true;
746+
}
747+
}
748+
628749
async function createChokidarWatcher(
629750
options?: WatcherOptions,
630751
chokidarModule?: typeof Chokidar,
@@ -647,15 +768,25 @@ async function createChokidarWatcher(
647768
const rootDirPosix = toPosixPathNormalized(rootDir);
648769
const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
649770

650-
const ignored = options?.ignored?.map((pattern) => {
651-
if (/[*?[\]{}()]/.test(pattern)) {
652-
const isMatch = picomatch(pattern, { dot: true });
771+
const nodeModulesManager = new ChokidarNodeModulesManager(isCaseSensitive);
653772

654-
return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
655-
}
773+
// Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher).
774+
// If left in place, picomatch would unconditionally ignore all node_modules files and override
775+
// our dynamic package allowlist above.
776+
const customIgnored =
777+
options?.ignored
778+
?.filter((pattern) => !pattern.includes(NODE_MODULES))
779+
.map((pattern) => {
780+
if (/[*?[\]{}()]/.test(pattern)) {
781+
const isMatch = picomatch(pattern, { dot: true });
656782

657-
return { path: toPosixPathNormalized(pattern), recursive: true };
658-
});
783+
return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
784+
}
785+
786+
return { path: toPosixPathNormalized(pattern), recursive: true };
787+
}) ?? [];
788+
789+
const ignored = [...customIgnored, (filePath: string) => nodeModulesManager.isIgnored(filePath)];
659790

660791
const watcher = chokidar.watch(rootDir, {
661792
ignoreInitial: true,
@@ -714,7 +845,17 @@ async function createChokidarWatcher(
714845
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
715846
if (!watchedFiles.has(lookupKey)) {
716847
watchedFiles.add(lookupKey);
717-
if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
848+
849+
// For files inside node_modules, register their package directory. Only newly encountered
850+
// package directories need to be added to Chokidar.
851+
const { isPackage, newPkgDir } = nodeModulesManager.registerPackage(posixPath, lookupKey);
852+
if (newPkgDir) {
853+
newPaths.push(newPkgDir);
854+
} else if (
855+
!isPackage &&
856+
!isPathInside(lookupKey, rootDirLookupKey) &&
857+
lookupKey !== rootDirLookupKey
858+
) {
718859
newPaths.push(posixPath);
719860
}
720861
}
@@ -732,7 +873,19 @@ async function createChokidarWatcher(
732873
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
733874
if (watchedFiles.has(lookupKey)) {
734875
watchedFiles.delete(lookupKey);
735-
if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
876+
877+
// When the last watched file in a package is removed, unwatch the package directory.
878+
const { isPackage, unwatchPkgDir } = nodeModulesManager.removePackageFile(
879+
posixPath,
880+
lookupKey,
881+
);
882+
if (unwatchPkgDir) {
883+
removePaths.push(unwatchPkgDir);
884+
} else if (
885+
!isPackage &&
886+
!isPathInside(lookupKey, rootDirLookupKey) &&
887+
lookupKey !== rootDirLookupKey
888+
) {
736889
removePaths.push(posixPath);
737890
}
738891
}

0 commit comments

Comments
 (0)