Skip to content

Commit a086147

Browse files
fix: narrow purge for watched directories in NodeWatchFileSystem (#21020)
1 parent 7dd1824 commit a086147

8 files changed

Lines changed: 129 additions & 68 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Reduce per-file overhead in `ContextModuleFactory.resolveDependencies` by batching `alternativeRequests` hook calls. Previously the hook was invoked once per file in the context (with a single-item array), paying per-call overhead (closure allocation, `resolverFactory.get`, intermediate arrays in `RequireContextPlugin`) for every file. The hook is now invoked once per directory with all matched files in one batch — `RequireContextPlugin`'s tap already iterates the items array, so the output is unchanged. Steady-state rebuild on a 4000-file `require.context` drops a further ~15 ms (after the watch-mode purge fix in the same release).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Fix slow `require.context()` / dynamic `import()` rebuilds in watch mode (#13636). When a file inside a watched context directory changed, `NodeWatchFileSystem` would call `inputFileSystem.purge(contextDir)`. The enhanced-resolve `purge` implementation matches cache keys with `key.startsWith(contextDir)`, so the stat cache of every file under the directory was discarded on every rebuild — `ContextModuleFactory.resolveDependencies` then re-`stat`-ed the whole tree on each rebuild. Single-file rebuilds on a 4000-file context now reuse the warm stat cache, dropping median rebuild from ~1260 ms to ~650 ms in a local reproduction (≈49%). For directory items that are explicitly watched contexts, `purge` is now called with `{ exact: true }` (added in `enhanced-resolve@5.22.0`) so only the directory's own entry is invalidated; file-level changes in the same aggregated event continue to purge file stats and the parent `readdir` as before.

lib/ContextModuleFactory.js

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ class ContextModuleFactory extends ModuleFactory {
379379
if (!processedFiles || processedFiles.length === 0) {
380380
return callback(null, []);
381381
}
382+
/** @type {ContextAlternativeRequest[]} */
383+
const fileObjs = [];
382384
asyncLib.map(
383385
processedFiles.filter((p) => p.indexOf(".") !== 0),
384386
(segment, callback) => {
@@ -404,41 +406,16 @@ class ContextModuleFactory extends ModuleFactory {
404406
stat.isFile() &&
405407
(!include || include.test(subResource))
406408
) {
407-
/** @type {{ context: string, request: string }} */
408-
const obj = {
409+
// Collect for a single batched alternativeRequests call
410+
// per directory below. Calling the hook once per file
411+
// would pay per-call overhead (closure, resolverFactory
412+
// lookup, array allocations) for every file in the
413+
// context — which is the bulk of work on rebuilds.
414+
fileObjs.push({
409415
context: ctx,
410416
request: `.${subResource.slice(ctx.length).replace(/\\/g, "/")}`
411-
};
412-
413-
this.hooks.alternativeRequests.callAsync(
414-
[obj],
415-
options,
416-
(err, alternatives) => {
417-
if (err) return callback(err);
418-
callback(
419-
null,
420-
/** @type {ContextAlternativeRequest[]} */
421-
(alternatives)
422-
.filter((obj) =>
423-
regExp.test(/** @type {string} */ (obj.request))
424-
)
425-
.map((obj) => {
426-
const dep = new ContextElementDependency(
427-
`${obj.request}${resourceQuery}${resourceFragment}`,
428-
obj.request,
429-
typePrefix,
430-
/** @type {string} */
431-
(category),
432-
referencedExports,
433-
obj.context,
434-
attributes
435-
);
436-
dep.optional = true;
437-
return dep;
438-
})
439-
);
440-
}
441-
);
417+
});
418+
callback();
442419
} else {
443420
callback();
444421
}
@@ -450,16 +427,46 @@ class ContextModuleFactory extends ModuleFactory {
450427
(err, result) => {
451428
if (err) return callback(err);
452429

453-
if (!result) return callback(null, []);
454-
455430
/** @type {ContextElementDependency[]} */
456431
const flattenedResult = [];
457432

458-
for (const item of result) {
459-
if (item) flattenedResult.push(...item);
433+
if (result) {
434+
for (const item of result) {
435+
if (item) flattenedResult.push(...item);
436+
}
460437
}
461438

462-
callback(null, flattenedResult);
439+
if (fileObjs.length === 0) {
440+
return callback(null, flattenedResult);
441+
}
442+
443+
this.hooks.alternativeRequests.callAsync(
444+
fileObjs,
445+
options,
446+
(err, alternatives) => {
447+
if (err) return callback(err);
448+
for (const alt of /** @type {ContextAlternativeRequest[]} */ (
449+
alternatives
450+
)) {
451+
if (!regExp.test(/** @type {string} */ (alt.request))) {
452+
continue;
453+
}
454+
const dep = new ContextElementDependency(
455+
`${alt.request}${resourceQuery}${resourceFragment}`,
456+
alt.request,
457+
typePrefix,
458+
/** @type {string} */
459+
(category),
460+
referencedExports,
461+
alt.context,
462+
attributes
463+
);
464+
dep.optional = true;
465+
flattenedResult.push(dep);
466+
}
467+
callback(null, flattenedResult);
468+
}
469+
);
463470
}
464471
);
465472
});

lib/node/NodeWatchFileSystem.js

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ class NodeWatchFileSystem {
8282
}
8383
return { fileTimeInfoEntries, contextTimeInfoEntries };
8484
};
85+
const directoriesSet =
86+
directories instanceof Set ? directories : new Set(directories);
87+
88+
// Watchpack reports a watched directory (a context dependency) in
89+
// `changes` whenever its contents change, alongside the individual
90+
// file events. The default `fs.purge(dir)` matches cache keys by
91+
// prefix, so it would wipe the stat cache of every file inside the
92+
// directory even though only file-level events actually invalidate
93+
// file stats. For directories we explicitly watch, purge only the
94+
// directory's own entry (`{ exact: true }`, enhanced-resolve >=
95+
// 5.22.0); file-level events in the same aggregated batch still
96+
// handle file stats and the parent readdir invalidation.
97+
/**
98+
* @param {Changes | null | undefined} changes changes set
99+
* @param {Removals | null | undefined} removals removals set
100+
*/
101+
const purgeChanges = (changes, removals) => {
102+
const fs = this.inputFileSystem;
103+
if (!fs || !fs.purge) return;
104+
if (changes) {
105+
for (const item of changes) {
106+
if (directoriesSet.has(item)) {
107+
fs.purge(item, { exact: true });
108+
} else {
109+
fs.purge(item);
110+
}
111+
}
112+
}
113+
if (removals) {
114+
for (const item of removals) {
115+
fs.purge(item);
116+
}
117+
}
118+
};
119+
85120
this.watcher.once(
86121
"aggregated",
87122
/**
@@ -94,15 +129,7 @@ class NodeWatchFileSystem {
94129
/** @type {Watchpack} */
95130
(this.watcher).pause();
96131

97-
const fs = this.inputFileSystem;
98-
if (fs && fs.purge) {
99-
for (const item of changes) {
100-
fs.purge(item);
101-
}
102-
for (const item of removals) {
103-
fs.purge(item);
104-
}
105-
}
132+
purgeChanges(changes, removals);
106133
const { fileTimeInfoEntries, contextTimeInfoEntries } = fetchTimeInfo();
107134
callback(
108135
null,
@@ -172,19 +199,7 @@ class NodeWatchFileSystem {
172199
getInfo: () => {
173200
const removals = this.watcher && this.watcher.aggregatedRemovals;
174201
const changes = this.watcher && this.watcher.aggregatedChanges;
175-
const fs = this.inputFileSystem;
176-
if (fs && fs.purge) {
177-
if (removals) {
178-
for (const item of removals) {
179-
fs.purge(item);
180-
}
181-
}
182-
if (changes) {
183-
for (const item of changes) {
184-
fs.purge(item);
185-
}
186-
}
187-
}
202+
purgeChanges(changes, removals);
188203
const { fileTimeInfoEntries, contextTimeInfoEntries } = fetchTimeInfo();
189204
return {
190205
changes,

lib/util/fs.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,12 @@ const path = require("path");
308308

309309
/**
310310
* Defines the purge type used by this module.
311-
* @typedef {(value?: string | string[] | Set<string>) => void} Purge
311+
*
312+
* `options.exact` (supported by enhanced-resolve's `CachedInputFileSystem`
313+
* from v5.22.0): when true, only entries whose key exactly matches `value`
314+
* are invalidated; cached entries for descendants are preserved. Default
315+
* is false (legacy prefix-match behavior).
316+
* @typedef {(value?: string | string[] | Set<string>, options?: { exact?: boolean }) => void} Purge
312317
*/
313318

314319
/**

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
"acorn-import-phases": "^1.0.3",
9898
"browserslist": "^4.28.1",
9999
"chrome-trace-event": "^1.0.2",
100-
"enhanced-resolve": "^5.21.4",
100+
"enhanced-resolve": "^5.22.0",
101101
"es-module-lexer": "^2.1.0",
102102
"eslint-scope": "5.1.1",
103103
"events": "^3.2.0",

types.d.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4038,6 +4038,27 @@ declare interface CompiledAliasOption {
40384038
*/
40394039
arrayAlias: boolean;
40404040
}
4041+
declare interface CompiledAliasOptions {
4042+
/**
4043+
* declaration-ordered list
4044+
*/
4045+
all: CompiledAliasOption[];
4046+
4047+
/**
4048+
* bucketed by first char code
4049+
*/
4050+
byFirstChar: Map<number, CompiledAliasOption[]>;
4051+
4052+
/**
4053+
* true when an empty-prefix wildcard is present
4054+
*/
4055+
hasAnyFirstChar: boolean;
4056+
4057+
/**
4058+
* true when the bucket fast-path should be used at resolve time
4059+
*/
4060+
useBuckets: boolean;
4061+
}
40414062
declare class Compiler {
40424063
/**
40434064
* Creates an instance of Compiler.
@@ -9658,7 +9679,10 @@ declare interface InputFileSystem {
96589679
) => void
96599680
) => void;
96609681
readJsonSync?: (pathOrFileDescriptor: PathOrFileDescriptorFs) => JsonObjectFs;
9661-
purge?: (value?: string | string[] | Set<string>) => void;
9682+
purge?: (
9683+
value?: string | string[] | Set<string>,
9684+
options?: { exact?: boolean }
9685+
) => void;
96629686
join?: (path1: string, path2: string) => string;
96639687
relative?: (from: string, to: string) => string;
96649688
dirname?: (dirname: string) => string;
@@ -23935,7 +23959,7 @@ declare interface TsconfigPathsData {
2393523959
/**
2393623960
* tsconfig file data
2393723961
*/
23938-
alias: CompiledAliasOption[];
23962+
alias: CompiledAliasOptions;
2393923963

2394023964
/**
2394123965
* tsconfig file data

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3874,10 +3874,10 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1:
38743874
graceful-fs "^4.2.4"
38753875
tapable "^2.3.3"
38763876

3877-
enhanced-resolve@^5.21.4:
3878-
version "5.21.5"
3879-
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
3880-
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
3877+
enhanced-resolve@^5.22.0:
3878+
version "5.22.0"
3879+
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz#43c5caad657c6fce58fc6142e5ca6fa8528ed460"
3880+
integrity sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==
38813881
dependencies:
38823882
graceful-fs "^4.2.4"
38833883
tapable "^2.3.3"

0 commit comments

Comments
 (0)