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,50 @@ 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 ) ;
1123-
1124- return { isPackage : true } ;
1125- }
1126-
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- }
1135-
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 ) ;
1142-
1143- return { isPackage : true , unwatchPkgDir : packageDir } ;
1144- }
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- }
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 ( / \/ \* \* $ / , '' ) ;
11551100
1156- const posixPath = toPosixPathNormalized ( filePath ) ;
1157- const lookupKey = toLookupKey ( posixPath , this . isCaseSensitive ) ;
1101+ return base !== NODE_MODULES && ! base . endsWith ( `/${ NODE_MODULES } ` ) ;
1102+ } )
1103+ . map ( ( pattern ) => {
1104+ if ( / [ * ? [ \] { } ( ) ] / . test ( pattern ) ) {
1105+ const isMatch = picomatch ( pattern , { dot : true } ) ;
11581106
1159- if ( ! `${ lookupKey } /` . includes ( NODE_MODULES_SEGMENT ) ) {
1160- return false ;
1161- }
1107+ return ( filePath : string ) => isMatch ( toPosixPathNormalized ( filePath ) ) ;
1108+ }
11621109
1163- if ( this . allowedPackages . has ( lookupKey ) ) {
1164- return false ;
1165- }
1110+ return { path : toPosixPathNormalized ( pattern ) , recursive : true } ;
1111+ } ) ?? [ ] ;
11661112
1167- for ( const pkg of this . allowedPackages . keys ( ) ) {
1168- if ( isPathInside ( lookupKey , pkg ) ) {
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 ) ) {
11691120 return false ;
11701121 }
1171- }
11721122
1173- return true ;
1174- }
1123+ const posixPath = toPosixPathNormalized ( filePath ) ;
1124+ const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
1125+
1126+ return (
1127+ ( lookupKey === NODE_MODULES || lookupKey . endsWith ( `/${ NODE_MODULES } ` ) ) &&
1128+ ! watchedFiles . has ( lookupKey )
1129+ ) ;
1130+ } ,
1131+ ] ;
11751132}
11761133
11771134async function createChokidarWatcher (
@@ -1181,57 +1138,30 @@ async function createChokidarWatcher(
11811138 const chokidar = chokidarModule ?? ( await import ( 'chokidar' ) ) ;
11821139 const watchedFiles = new Set < string > ( ) ;
11831140 const fileStats = new Map < string , { mtimeMs : number ; size : number } > ( ) ;
1141+ const pendingTimers = new Set < ReturnType < typeof setTimeout > > ( ) ;
11841142
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- }
1143+ const pollingInterval = options ?. interval ?? 100 ;
1144+ const debounceMs = Math . min ( 250 , Math . max ( 100 , Math . ceil ( pollingInterval * 1.5 ) ) ) ;
1145+ const queue = options ?. polling
1146+ ? new WatcherQueue ( debounceMs , Math . max ( 500 , debounceMs * 3 ) )
1147+ : new WatcherQueue ( ) ;
11941148
11951149 const rootDir = options ?. cwd ?? process . cwd ( ) ;
11961150 const isCaseSensitive = isFileSystemCaseSensitive ( rootDir ) ;
1197- const rootDirPosix = toPosixPathNormalized ( rootDir ) ;
1198- const rootDirLookupKey = toLookupKey ( rootDirPosix , isCaseSensitive ) ;
1199-
1200- const nodeModulesManager = new ChokidarNodeModulesManager ( isCaseSensitive ) ;
1151+ const ignored = createChokidarIgnored ( options ?. ignored , isCaseSensitive , watchedFiles ) ;
12011152
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 , {
1153+ const watcher = chokidar . watch ( [ ] , {
12211154 ignoreInitial : true ,
12221155 ignored,
12231156 followSymlinks : options ?. followSymlinks ,
12241157 usePolling : ! ! options ?. polling ,
12251158 interval : options ?. interval ,
12261159 } ) ;
12271160
1228- const initTime = Date . now ( ) ;
1161+ // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process.
1162+ watcher . on ( 'error' , ( ) => { } ) ;
12291163
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' ) ;
1164+ const initTime = Date . now ( ) ;
12351165
12361166 const handleEvent = ( type : 'added' | 'modified' | 'removed' , rawPath : string ) => {
12371167 const posixPath = toPosixPathNormalized ( rawPath ) ;
@@ -1248,14 +1178,10 @@ async function createChokidarWatcher(
12481178 }
12491179
12501180 const stat = fs . statSync ( rawPath , { throwIfNoEntry : false } ) ;
1251- if ( ! stat ) {
1252- return ;
1253- }
1254-
12551181 // Ignore historical events from before watcher initialization, but allow a 1000 ms window
12561182 // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
12571183 // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
1258- if ( stat . mtimeMs < initTime - 1000 ) {
1184+ if ( ! stat || stat . mtimeMs < initTime - 1000 ) {
12591185 return ;
12601186 }
12611187
@@ -1275,9 +1201,31 @@ async function createChokidarWatcher(
12751201
12761202 watcher . on ( 'add' , ( path ) => handleEvent ( 'added' , path ) ) ;
12771203 watcher . on ( 'change' , ( path ) => handleEvent ( 'modified' , path ) ) ;
1278- watcher . on ( 'unlink' , ( path ) => handleEvent ( 'removed' , path ) ) ;
1204+ watcher . on ( 'unlink' , ( rawPath ) => {
1205+ const posixPath = toPosixPathNormalized ( rawPath ) ;
1206+ const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
1207+ if ( ! isPathWatched ( lookupKey , watchedFiles ) ) {
1208+ return ;
1209+ }
12791210
1280- const buildWatcher : BuildWatcher = {
1211+ // When Chokidar processes a file removal, it closes the underlying file watcher unless only a
1212+ // single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path
1213+ // ensures Chokidar continues watching either the recreated file (if replaced within the atomic
1214+ // window) or its parent directory for future recreation.
1215+ if ( watchedFiles . has ( lookupKey ) ) {
1216+ fileStats . delete ( lookupKey ) ;
1217+ watcher . add ( posixPath ) ;
1218+ if ( fs . existsSync ( rawPath ) ) {
1219+ handleEvent ( 'modified' , rawPath ) ;
1220+
1221+ return ;
1222+ }
1223+ }
1224+
1225+ handleEvent ( 'removed' , rawPath ) ;
1226+ } ) ;
1227+
1228+ return {
12811229 [ Symbol . asyncIterator ] ( ) {
12821230 return this ;
12831231 } ,
@@ -1304,28 +1252,51 @@ async function createChokidarWatcher(
13041252 add ( paths ) {
13051253 const targets = typeof paths === 'string' ? [ paths ] : paths ;
13061254 const newPaths : string [ ] = [ ] ;
1255+ const baselineStats = new Map < string , { posixPath : string ; mtimeMs : number ; size : number } > ( ) ;
1256+
13071257 for ( const p of targets ) {
13081258 const posixPath = toPosixPathNormalized ( p ) ;
13091259 const lookupKey = toLookupKey ( posixPath , isCaseSensitive ) ;
13101260 if ( ! watchedFiles . has ( lookupKey ) ) {
13111261 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 ) ;
1262+ newPaths . push ( posixPath ) ;
1263+ const stat = fs . statSync ( posixPath , { throwIfNoEntry : false } ) ;
1264+ if ( stat ?. isFile ( ) ) {
1265+ baselineStats . set ( lookupKey , { posixPath, mtimeMs : stat . mtimeMs , size : stat . size } ) ;
13241266 }
13251267 }
13261268 }
13271269 if ( newPaths . length > 0 ) {
13281270 watcher . add ( newPaths ) ;
1271+
1272+ if ( baselineStats . size > 0 ) {
1273+ // Chokidar initializes file watchers asynchronously (`await stat(path)` followed by
1274+ // `fs.watch` or `fs.watchFile`). If a file is modified immediately after `add()` returns
1275+ // (for example, in tests that write to disk right after the initial build yields),
1276+ // Chokidar may capture the post-modification state as its initial baseline and miss the
1277+ // change. Re-statting after a short delay catches any modifications that occurred during
1278+ // asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired.
1279+ const timer = setTimeout ( ( ) => {
1280+ pendingTimers . delete ( timer ) ;
1281+ void Promise . all (
1282+ Array . from ( baselineStats , async ( [ lookupKey , baseline ] ) => {
1283+ if ( ! watchedFiles . has ( lookupKey ) ) {
1284+ return ;
1285+ }
1286+ const currentStat = await fs . promises . stat ( baseline . posixPath ) . catch ( ( ) => null ) ;
1287+ if (
1288+ watchedFiles . has ( lookupKey ) &&
1289+ currentStat ?. isFile ( ) &&
1290+ ( currentStat . mtimeMs !== baseline . mtimeMs || currentStat . size !== baseline . size )
1291+ ) {
1292+ handleEvent ( 'modified' , path . normalize ( baseline . posixPath ) ) ;
1293+ }
1294+ } ) ,
1295+ ) ;
1296+ } , 50 ) ;
1297+ timer . unref ( ) ;
1298+ pendingTimers . add ( timer ) ;
1299+ }
13291300 }
13301301 } ,
13311302
@@ -1338,21 +1309,7 @@ async function createChokidarWatcher(
13381309 if ( watchedFiles . has ( lookupKey ) ) {
13391310 watchedFiles . delete ( lookupKey ) ;
13401311 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- }
1312+ removePaths . push ( posixPath ) ;
13561313 }
13571314 }
13581315 if ( removePaths . length > 0 ) {
@@ -1361,13 +1318,15 @@ async function createChokidarWatcher(
13611318 } ,
13621319
13631320 async close ( ) {
1321+ for ( const timer of pendingTimers ) {
1322+ clearTimeout ( timer ) ;
1323+ }
1324+ pendingTimers . clear ( ) ;
13641325 try {
13651326 await watcher . close ( ) ;
13661327 } finally {
13671328 queue . close ( ) ;
13681329 }
13691330 } ,
1370- } ;
1371-
1372- return buildWatcher ;
1331+ } satisfies BuildWatcher ;
13731332}
0 commit comments