88
99import type * as ParcelWatcher from '@parcel/watcher' ;
1010import type * as Chokidar from 'chokidar' ;
11- import { once } from 'node:events' ;
1211import * as fs from 'node:fs' ;
1312import * as path from 'node:path' ;
1413import picomatch from 'picomatch' ;
@@ -1086,92 +1085,94 @@ async function createParcelWatcher(
10861085 return buildWatcher ;
10871086}
10881087
1089- /**
1090- * Manages selective node_modules watching for Chokidar.
1091- *
1092- * During Chokidar's initial scan, the allowed packages set is empty, causing Chokidar to prune the entire
1093- * node_modules directory immediately and avoid blocking the 'ready' event for minutes.
1094- * Packages are dynamically allowed and watched at the package root level as files are added via `watcher.add`.
1095- * Tracking files per package allows reference-counting so that package directories are unwatched when no
1096- * longer needed.
1097- */
1098- class ChokidarNodeModulesManager {
1099- private readonly allowedPackages = new Map < string , Set < string > > ( ) ;
1100-
1101- constructor ( private readonly isCaseSensitive : boolean ) { }
1102-
1103- registerPackage (
1104- posixPath : string ,
1105- lookupKey : string ,
1106- ) : { isPackage : boolean ; newPkgDir ?: string } {
1107- const packageDir = extractNodeModulesPackageDir ( posixPath ) ;
1108- if ( ! packageDir ) {
1109- return { isPackage : false } ;
1110- }
1111-
1112- const packageKey = toLookupKey ( packageDir , this . isCaseSensitive ) ;
1113- let watchedInPkg = this . allowedPackages . get ( packageKey ) ;
1114- if ( ! watchedInPkg ) {
1115- watchedInPkg = new Set ( ) ;
1116- this . allowedPackages . set ( packageKey , watchedInPkg ) ;
1117- watchedInPkg . add ( lookupKey ) ;
1118-
1119- return { isPackage : true , newPkgDir : packageDir } ;
1120- }
1121-
1122- watchedInPkg . add ( lookupKey ) ;
1088+ function createChokidarIgnored (
1089+ ignoredPatterns : string [ ] | undefined ,
1090+ isCaseSensitive : boolean ,
1091+ watchedFiles : ReadonlySet < string > ,
1092+ ) : Chokidar . Matcher [ ] {
1093+ // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher).
1094+ // If left in place, picomatch would unconditionally ignore all node_modules files and prevent
1095+ // watching explicitly added files inside node_modules.
1096+ const customIgnored =
1097+ ignoredPatterns
1098+ ?. filter ( ( pattern ) => {
1099+ const base = toPosixPathNormalized ( pattern ) . replace ( / \/ \* \* $ / , '' ) ;
11231100
1124- return { isPackage : true } ;
1125- }
1101+ return base !== NODE_MODULES && ! base . endsWith ( `/${ NODE_MODULES } ` ) ;
1102+ } )
1103+ . map ( ( pattern ) => {
1104+ if ( / [ * ? [ \] { } ( ) ] / . test ( pattern ) ) {
1105+ const isMatch = picomatch ( pattern , { dot : true } ) ;
11261106
1127- removePackageFile (
1128- posixPath : string ,
1129- lookupKey : string ,
1130- ) : { isPackage : boolean ; unwatchPkgDir ?: string } {
1131- const packageDir = extractNodeModulesPackageDir ( posixPath ) ;
1132- if ( ! packageDir ) {
1133- return { isPackage : false } ;
1134- }
1107+ return ( filePath : string ) => isMatch ( toPosixPathNormalized ( filePath ) ) ;
1108+ }
11351109
1136- const packageKey = toLookupKey ( packageDir , this . isCaseSensitive ) ;
1137- const watchedInPkg = this . allowedPackages . get ( packageKey ) ;
1138- if ( watchedInPkg ) {
1139- watchedInPkg . delete ( lookupKey ) ;
1140- if ( watchedInPkg . size === 0 ) {
1141- this . allowedPackages . delete ( packageKey ) ;
1110+ return { path : toPosixPathNormalized ( pattern ) , recursive : true } ;
1111+ } ) ?? [ ] ;
11421112
1143- return { isPackage : true , unwatchPkgDir : packageDir } ;
1113+ // When watching a directory (e.g. projectRoot via NG_BUILD_WATCH_ROOT), prune `node_modules`
1114+ // directories unless explicitly added to `watchedFiles`. Explicitly added files inside
1115+ // `node_modules` do not end with `/node_modules` and remain watchable.
1116+ return [
1117+ ...customIgnored ,
1118+ ( filePath : string ) => {
1119+ if ( ! filePath . includes ( NODE_MODULES ) ) {
1120+ return false ;
11441121 }
1145- }
1146-
1147- return { isPackage : true } ;
1148- }
1149-
1150- isIgnored ( filePath : string ) : boolean {
1151- // Fast path: avoid expensive normalization for non-node_modules files
1152- if ( ! filePath . includes ( NODE_MODULES ) ) {
1153- return false ;
1154- }
11551122
1156- const posixPath = toPosixPathNormalized ( filePath ) ;
1157- const lookupKey = toLookupKey ( posixPath , this . isCaseSensitive ) ;
1123+ const posixPath = toPosixPathNormalized ( filePath ) ;
1124+ const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
11581125
1159- if ( ! `${ lookupKey } /` . includes ( NODE_MODULES_SEGMENT ) ) {
1160- return false ;
1161- }
1126+ return (
1127+ ( lookupKey === NODE_MODULES || lookupKey . endsWith ( `/${ NODE_MODULES } ` ) ) &&
1128+ ! watchedFiles . has ( lookupKey )
1129+ ) ;
1130+ } ,
1131+ ] ;
1132+ }
11621133
1163- if ( this . allowedPackages . has ( lookupKey ) ) {
1164- return false ;
1165- }
1134+ interface ChokidarBaselineEntry {
1135+ posixPath : string ;
1136+ mtimeMs ?: number ;
1137+ size ?: number ;
1138+ }
11661139
1167- for ( const pkg of this . allowedPackages . keys ( ) ) {
1168- if ( isPathInside ( lookupKey , pkg ) ) {
1169- return false ;
1140+ // Chokidar initializes file watchers asynchronously (`await stat(path)` followed by
1141+ // `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after
1142+ // `watcher.add()` is called, Chokidar may capture the post-modification state as its initial
1143+ // baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the
1144+ // change. Re-statting after a short delay catches any modifications or recreations that occurred
1145+ // during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired.
1146+ async function checkChokidarBaselines (
1147+ baselineStats : ReadonlyMap < string , ChokidarBaselineEntry > ,
1148+ watchedFiles : ReadonlySet < string > ,
1149+ fileStats : ReadonlyMap < string , { mtimeMs : number ; size : number } > ,
1150+ watcher : Chokidar . FSWatcher ,
1151+ handleEvent : ( type : 'added' | 'modified' | 'removed' , rawPath : string ) => void ,
1152+ ) : Promise < void > {
1153+ await Promise . all (
1154+ Array . from ( baselineStats , async ( [ lookupKey , baseline ] ) => {
1155+ if ( ! watchedFiles . has ( lookupKey ) ) {
1156+ return ;
1157+ }
1158+ const currentStat = await fs . promises . stat ( baseline . posixPath ) . catch ( ( ) => null ) ;
1159+ if ( ! watchedFiles . has ( lookupKey ) || ! currentStat ?. isFile ( ) ) {
1160+ return ;
11701161 }
1171- }
11721162
1173- return true ;
1174- }
1163+ if ( baseline . mtimeMs === undefined || baseline . size === undefined ) {
1164+ const recorded = fileStats . get ( lookupKey ) ;
1165+ if ( ! recorded ) {
1166+ watcher . add ( baseline . posixPath ) ;
1167+ handleEvent ( 'added' , path . normalize ( baseline . posixPath ) ) ;
1168+ } else if ( currentStat . mtimeMs !== recorded . mtimeMs || currentStat . size !== recorded . size ) {
1169+ handleEvent ( 'modified' , path . normalize ( baseline . posixPath ) ) ;
1170+ }
1171+ } else if ( currentStat . mtimeMs !== baseline . mtimeMs || currentStat . size !== baseline . size ) {
1172+ handleEvent ( 'modified' , path . normalize ( baseline . posixPath ) ) ;
1173+ }
1174+ } ) ,
1175+ ) ;
11751176}
11761177
11771178async function createChokidarWatcher (
@@ -1181,57 +1182,30 @@ async function createChokidarWatcher(
11811182 const chokidar = chokidarModule ?? ( await import ( 'chokidar' ) ) ;
11821183 const watchedFiles = new Set < string > ( ) ;
11831184 const fileStats = new Map < string , { mtimeMs : number ; size : number } > ( ) ;
1185+ const pendingTimers = new Set < ReturnType < typeof setTimeout > > ( ) ;
11841186
1185- let queue : WatcherQueue ;
1186- if ( options ?. polling ) {
1187- const pollingInterval = options . interval ?? 100 ;
1188- const debounceMs = Math . min ( 250 , Math . max ( 100 , Math . ceil ( pollingInterval * 1.5 ) ) ) ;
1189- const maxWaitMs = Math . max ( 500 , debounceMs * 3 ) ;
1190- queue = new WatcherQueue ( debounceMs , maxWaitMs ) ;
1191- } else {
1192- queue = new WatcherQueue ( ) ;
1193- }
1187+ const pollingInterval = options ?. interval ?? 100 ;
1188+ const debounceMs = Math . min ( 250 , Math . max ( 100 , Math . ceil ( pollingInterval * 1.5 ) ) ) ;
1189+ const queue = options ?. polling
1190+ ? new WatcherQueue ( debounceMs , Math . max ( 500 , debounceMs * 3 ) )
1191+ : new WatcherQueue ( ) ;
11941192
11951193 const rootDir = options ?. cwd ?? process . cwd ( ) ;
11961194 const isCaseSensitive = isFileSystemCaseSensitive ( rootDir ) ;
1197- const rootDirPosix = toPosixPathNormalized ( rootDir ) ;
1198- const rootDirLookupKey = toLookupKey ( rootDirPosix , isCaseSensitive ) ;
1199-
1200- const nodeModulesManager = new ChokidarNodeModulesManager ( isCaseSensitive ) ;
1195+ const ignored = createChokidarIgnored ( options ?. ignored , isCaseSensitive , watchedFiles ) ;
12011196
1202- // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher).
1203- // If left in place, picomatch would unconditionally ignore all node_modules files and override
1204- // our dynamic package allowlist above.
1205- const customIgnored =
1206- options ?. ignored
1207- ?. filter ( ( pattern ) => ! pattern . includes ( NODE_MODULES ) )
1208- . map ( ( pattern ) => {
1209- if ( / [ * ? [ \] { } ( ) ] / . test ( pattern ) ) {
1210- const isMatch = picomatch ( pattern , { dot : true } ) ;
1211-
1212- return ( filePath : string ) => isMatch ( toPosixPathNormalized ( filePath ) ) ;
1213- }
1214-
1215- return { path : toPosixPathNormalized ( pattern ) , recursive : true } ;
1216- } ) ?? [ ] ;
1217-
1218- const ignored = [ ...customIgnored , ( filePath : string ) => nodeModulesManager . isIgnored ( filePath ) ] ;
1219-
1220- const watcher = chokidar . watch ( rootDir , {
1197+ const watcher = chokidar . watch ( [ ] , {
12211198 ignoreInitial : true ,
12221199 ignored,
12231200 followSymlinks : options ?. followSymlinks ,
12241201 usePolling : ! ! options ?. polling ,
12251202 interval : options ?. interval ,
12261203 } ) ;
12271204
1228- const initTime = Date . now ( ) ;
1205+ // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process.
1206+ watcher . on ( 'error' , ( ) => { } ) ;
12291207
1230- // Wait for the watcher to complete its initial filesystem scan before returning.
1231- // With `ignoreInitial: true`, any file visited during the initial scan is treated as the initial baseline
1232- // and will not emit 'add' or 'change' events. Awaiting 'ready' ensures that rapid file modifications
1233- // made right after watcher setup (e.g. in rebuild tests) are not swallowed as initial files.
1234- await once ( watcher , 'ready' ) ;
1208+ const initTime = Date . now ( ) ;
12351209
12361210 const handleEvent = ( type : 'added' | 'modified' | 'removed' , rawPath : string ) => {
12371211 const posixPath = toPosixPathNormalized ( rawPath ) ;
@@ -1248,14 +1222,10 @@ async function createChokidarWatcher(
12481222 }
12491223
12501224 const stat = fs . statSync ( rawPath , { throwIfNoEntry : false } ) ;
1251- if ( ! stat ) {
1252- return ;
1253- }
1254-
12551225 // Ignore historical events from before watcher initialization, but allow a 1000 ms window
12561226 // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
12571227 // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
1258- if ( stat . mtimeMs < initTime - 1000 ) {
1228+ if ( ! stat || stat . mtimeMs < initTime - 1000 ) {
12591229 return ;
12601230 }
12611231
@@ -1273,11 +1243,55 @@ async function createChokidarWatcher(
12731243 queue . addChange ( type , rawPath ) ;
12741244 } ;
12751245
1246+ const scheduleBaselineCheck = ( baselineStats : ReadonlyMap < string , ChokidarBaselineEntry > ) => {
1247+ if ( baselineStats . size === 0 ) {
1248+ return ;
1249+ }
1250+
1251+ const timer = setTimeout ( ( ) => {
1252+ pendingTimers . delete ( timer ) ;
1253+ void checkChokidarBaselines ( baselineStats , watchedFiles , fileStats , watcher , handleEvent ) ;
1254+ } , 50 ) ;
1255+ timer . unref ( ) ;
1256+ pendingTimers . add ( timer ) ;
1257+ } ;
1258+
12761259 watcher . on ( 'add' , ( path ) => handleEvent ( 'added' , path ) ) ;
12771260 watcher . on ( 'change' , ( path ) => handleEvent ( 'modified' , path ) ) ;
1278- watcher . on ( 'unlink' , ( path ) => handleEvent ( 'removed' , path ) ) ;
1261+ watcher . on ( 'unlink' , ( rawPath ) => {
1262+ const posixPath = toPosixPathNormalized ( rawPath ) ;
1263+ const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
1264+ if ( ! isPathWatched ( lookupKey , watchedFiles ) ) {
1265+ return ;
1266+ }
12791267
1280- const buildWatcher : BuildWatcher = {
1268+ // When Chokidar processes a file removal, it closes the underlying file watcher unless only a
1269+ // single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path
1270+ // ensures Chokidar continues watching either the recreated file (if replaced within the atomic
1271+ // window) or its parent directory for future recreation.
1272+ if ( watchedFiles . has ( lookupKey ) ) {
1273+ fileStats . delete ( lookupKey ) ;
1274+ watcher . add ( posixPath ) ;
1275+ const stat = fs . statSync ( rawPath , { throwIfNoEntry : false } ) ;
1276+ if ( stat ?. isFile ( ) ) {
1277+ handleEvent ( 'modified' , rawPath ) ;
1278+ scheduleBaselineCheck (
1279+ new Map ( [ [ lookupKey , { posixPath, mtimeMs : stat . mtimeMs , size : stat . size } ] ] ) ,
1280+ ) ;
1281+
1282+ return ;
1283+ }
1284+
1285+ handleEvent ( 'removed' , rawPath ) ;
1286+ scheduleBaselineCheck ( new Map ( [ [ lookupKey , { posixPath } ] ] ) ) ;
1287+
1288+ return ;
1289+ }
1290+
1291+ handleEvent ( 'removed' , rawPath ) ;
1292+ } ) ;
1293+
1294+ return {
12811295 [ Symbol . asyncIterator ] ( ) {
12821296 return this ;
12831297 } ,
@@ -1304,28 +1318,23 @@ async function createChokidarWatcher(
13041318 add ( paths ) {
13051319 const targets = typeof paths === 'string' ? [ paths ] : paths ;
13061320 const newPaths : string [ ] = [ ] ;
1321+ const baselineStats = new Map < string , ChokidarBaselineEntry > ( ) ;
1322+
13071323 for ( const p of targets ) {
13081324 const posixPath = toPosixPathNormalized ( p ) ;
13091325 const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
13101326 if ( ! watchedFiles . has ( lookupKey ) ) {
13111327 watchedFiles . add ( lookupKey ) ;
1312-
1313- // For files inside node_modules, register their package directory. Only newly encountered
1314- // package directories need to be added to Chokidar.
1315- const { isPackage, newPkgDir } = nodeModulesManager . registerPackage ( posixPath , lookupKey ) ;
1316- if ( newPkgDir ) {
1317- newPaths . push ( newPkgDir ) ;
1318- } else if (
1319- ! isPackage &&
1320- ! isPathInside ( lookupKey , rootDirLookupKey ) &&
1321- lookupKey !== rootDirLookupKey
1322- ) {
1323- newPaths . push ( posixPath ) ;
1328+ newPaths . push ( posixPath ) ;
1329+ const stat = fs . statSync ( posixPath , { throwIfNoEntry : false } ) ;
1330+ if ( stat ?. isFile ( ) ) {
1331+ baselineStats . set ( lookupKey , { posixPath, mtimeMs : stat . mtimeMs , size : stat . size } ) ;
13241332 }
13251333 }
13261334 }
13271335 if ( newPaths . length > 0 ) {
13281336 watcher . add ( newPaths ) ;
1337+ scheduleBaselineCheck ( baselineStats ) ;
13291338 }
13301339 } ,
13311340
@@ -1338,21 +1347,7 @@ async function createChokidarWatcher(
13381347 if ( watchedFiles . has ( lookupKey ) ) {
13391348 watchedFiles . delete ( lookupKey ) ;
13401349 fileStats . delete ( lookupKey ) ;
1341-
1342- // When the last watched file in a package is removed, unwatch the package directory.
1343- const { isPackage, unwatchPkgDir } = nodeModulesManager . removePackageFile (
1344- posixPath ,
1345- lookupKey ,
1346- ) ;
1347- if ( unwatchPkgDir ) {
1348- removePaths . push ( unwatchPkgDir ) ;
1349- } else if (
1350- ! isPackage &&
1351- ! isPathInside ( lookupKey , rootDirLookupKey ) &&
1352- lookupKey !== rootDirLookupKey
1353- ) {
1354- removePaths . push ( posixPath ) ;
1355- }
1350+ removePaths . push ( posixPath ) ;
13561351 }
13571352 }
13581353 if ( removePaths . length > 0 ) {
@@ -1361,13 +1356,15 @@ async function createChokidarWatcher(
13611356 } ,
13621357
13631358 async close ( ) {
1359+ for ( const timer of pendingTimers ) {
1360+ clearTimeout ( timer ) ;
1361+ }
1362+ pendingTimers . clear ( ) ;
13641363 try {
13651364 await watcher . close ( ) ;
13661365 } finally {
13671366 queue . close ( ) ;
13681367 }
13691368 } ,
1370- } ;
1371-
1372- return buildWatcher ;
1369+ } satisfies BuildWatcher ;
13731370}
0 commit comments