@@ -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
5356const 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+
389422class 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+
628749async 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