Skip to content

Commit eb10e07

Browse files
perf(copy-plugin): reuse unchanged patterns for lazy rebuilds
Carry explicit lazy-watch provenance across the native boundary so an empty lazy activation can reuse static Copy results without making normal invalidations or repeated compiler.run calls stale. Preserve the existing Rust and three-argument binding APIs, add Perfetto attribution, and cover the 2,000-file and freshness regressions.
1 parent ef90a0f commit eb10e07

8 files changed

Lines changed: 179 additions & 14 deletions

File tree

crates/node_binding/napi-binding.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export declare class JsCompiler {
340340
/** Build with the given option passed to the constructor */
341341
build(callback: (err: null | Error) => void): void
342342
/** Rebuild with the given option passed to the constructor */
343-
rebuild(changed_files: string[], removed_files: string[], callback: (err: null | Error) => void): void
343+
rebuild(changed_files: string[], removed_files: string[], callback: (err: null | Error) => void, is_lazy_watch_rebuild?: boolean): void
344344
close(): Promise<void>
345345
getVirtualFileStore(): VirtualFileStore | null
346346
getCompilerId(): ExternalObject<CompilerId>

crates/rspack_binding_api/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,24 +390,26 @@ impl JsCompiler {
390390

391391
/// Rebuild with the given option passed to the constructor
392392
#[napi(
393-
ts_args_type = "changed_files: string[], removed_files: string[], callback: (err: null | Error) => void"
393+
ts_args_type = "changed_files: string[], removed_files: string[], callback: (err: null | Error) => void, is_lazy_watch_rebuild?: boolean"
394394
)]
395395
pub fn rebuild(
396396
&mut self,
397397
reference: Reference<JsCompiler>,
398398
changed_files: Vec<String>,
399399
removed_files: Vec<String>,
400400
f: Function<'static>,
401+
is_lazy_watch_rebuild: Option<bool>,
401402
) -> Result<(), ErrorCode> {
402403
unsafe {
403404
self.run(reference, |compiler, guard| {
404405
callbackify(
405406
f,
406407
async move {
407408
let result = compiler
408-
.rebuild(
409+
.rebuild_with_invalidation_provenance(
409410
changed_files.into_iter().collect::<FxHashSet<_>>(),
410411
removed_files.into_iter().collect::<FxHashSet<_>>(),
412+
is_lazy_watch_rebuild.unwrap_or(false),
411413
)
412414
.await
413415
.to_napi_result_with_message(|e| {

crates/rspack_core/src/compilation/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ pub struct Compilation {
316316
///
317317
/// Rebuild will include previous compilation data, so persistent cache will not recovery anything
318318
pub is_rebuild: bool,
319+
/// Whether this rebuild was explicitly invalidated by lazy compilation.
320+
pub is_lazy_watch_rebuild: bool,
319321
pub compiler_context: Arc<CompilerContext>,
320322
}
321323

@@ -445,6 +447,7 @@ impl Compilation {
445447
intermediate_filesystem,
446448
output_filesystem,
447449
is_rebuild,
450+
is_lazy_watch_rebuild: false,
448451
compiler_context,
449452
}
450453
}

crates/rspack_core/src/compiler/rebuild.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,21 @@ impl Compiler {
2020
&mut self,
2121
changed_files: FxHashSet<String>,
2222
deleted_files: FxHashSet<String>,
23+
) -> Result<()> {
24+
self
25+
.rebuild_with_invalidation_provenance(changed_files, deleted_files, false)
26+
.await
27+
}
28+
29+
pub async fn rebuild_with_invalidation_provenance(
30+
&mut self,
31+
changed_files: FxHashSet<String>,
32+
deleted_files: FxHashSet<String>,
33+
is_lazy_watch_rebuild: bool,
2334
) -> Result<()> {
2435
match within_compiler_context(
2536
self.compiler_context.clone(),
26-
self.rebuild_inner(changed_files, deleted_files),
37+
self.rebuild_inner(changed_files, deleted_files, is_lazy_watch_rebuild),
2738
)
2839
.await
2940
{
@@ -50,12 +61,14 @@ impl Compiler {
5061

5162
#[tracing::instrument("Compiler:rebuild", skip_all, fields(
5263
compiler.changed_files = ?changed_files.iter().cloned().collect::<Vec<_>>(),
53-
compiler.deleted_files = ?deleted_files.iter().cloned().collect::<Vec<_>>()
64+
compiler.deleted_files = ?deleted_files.iter().cloned().collect::<Vec<_>>(),
65+
compiler.is_lazy_watch_rebuild = is_lazy_watch_rebuild
5466
))]
5567
async fn rebuild_inner(
5668
&mut self,
5769
changed_files: FxHashSet<String>,
5870
deleted_files: FxHashSet<String>,
71+
is_lazy_watch_rebuild: bool,
5972
) -> Result<()> {
6073
let records = self.last_records.clone();
6174

@@ -93,6 +106,7 @@ impl Compiler {
93106
true,
94107
self.compiler_context.clone(),
95108
);
109+
next_compilation.is_lazy_watch_rebuild = is_lazy_watch_rebuild;
96110
next_compilation.hot_index = self.compilation.hot_index + 1;
97111

98112
if next_compilation

crates/rspack_plugin_copy/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,9 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
676676
let cached = &mut pattern_cache[index];
677677

678678
if cacheable
679+
&& (compilation.is_lazy_watch_rebuild
680+
|| !compilation.modified_files.is_empty()
681+
|| !compilation.removed_files.is_empty())
679682
&& let Some(cached) = cached.as_ref()
680683
&& !cached.is_invalidated(
681684
compilation

crates/rspack_plugin_copy/src/pattern_cache.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ pub(super) struct CachedPatternResult {
1010
}
1111

1212
impl CachedPatternResult {
13-
pub(super) fn is_invalidated<'a>(&self, changed_paths: impl Iterator<Item = &'a Path>) -> bool {
14-
let mut changed_paths = changed_paths.peekable();
15-
if changed_paths.peek().is_none() {
16-
return true;
17-
}
18-
13+
pub(super) fn is_invalidated<'a>(
14+
&self,
15+
mut changed_paths: impl Iterator<Item = &'a Path>,
16+
) -> bool {
1917
changed_paths.any(|changed| {
2018
self
2119
.file_dependencies
@@ -56,10 +54,10 @@ mod tests {
5654
}
5755

5856
#[test]
59-
fn reuses_only_when_unrelated_watcher_provenance_exists() {
57+
fn reuses_for_empty_and_unrelated_changes() {
6058
let cached = cached(&["/project/assets/file.txt"], &["/project/assets/glob"]);
6159

6260
assert!(!cached.is_invalidated([Path::new("/project/src/index.js")].into_iter()));
63-
assert!(cached.is_invalidated(std::iter::empty()));
61+
assert!(!cached.is_invalidated(std::iter::empty()));
6462
}
6563
}

packages/rspack/src/Compiler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,7 @@ class Compiler {
848848
Array.from(this.modifiedFiles || []),
849849
Array.from(this.removedFiles || []),
850850
callback,
851+
this.__internal__watchInvalidationKind === 'lazy',
851852
);
852853
return;
853854
}

tests/rspack-test/CopyPluginCache.test.js

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
const fs = require("node:fs");
22
const path = require("node:path");
3-
const { CopyRspackPlugin, Stats, rspack } = require("@rspack/core");
3+
const {
4+
CopyRspackPlugin,
5+
Stats,
6+
lazyCompilationMiddleware,
7+
rspack
8+
} = require("@rspack/core");
49

510
const fixtureRoot = path.resolve(__dirname, "js/copy-plugin-cache");
611

@@ -112,6 +117,9 @@ function watch(compiler) {
112117
return new Promise((resolve, reject) => {
113118
watching.close(error => (error ? reject(error) : resolve()));
114119
});
120+
},
121+
invalidate() {
122+
watching.invalidate();
115123
}
116124
};
117125
}
@@ -384,6 +392,142 @@ describe("CopyRspackPlugin pattern cache", () => {
384392
}
385393
});
386394

395+
it("reuses a 2000-file copy pattern during an empty lazy watch rebuild", async () => {
396+
const fileCount = 2000;
397+
const root = path.join(fixtureRoot, "lazy-watch-cache-hit");
398+
const beforeWatch = new Date(Date.now() - 3000);
399+
fs.rmSync(root, { recursive: true, force: true });
400+
const entry = write(root, "src/index.js", "export const value = 'lazy';\n");
401+
for (let index = 0; index < fileCount; index += 1) {
402+
const asset = write(root, `assets/${index}.txt`, `${index}\n`);
403+
fs.utimesSync(asset, beforeWatch, beforeWatch);
404+
}
405+
for (const filename of [
406+
path.join(root, "src"),
407+
path.join(root, "assets"),
408+
entry
409+
]) {
410+
fs.utimesSync(filename, beforeWatch, beforeWatch);
411+
}
412+
413+
const compiler = rspack({
414+
context: root,
415+
mode: "development",
416+
target: "web",
417+
devtool: false,
418+
cache: true,
419+
incremental: true,
420+
entry: "./src/index.js",
421+
lazyCompilation: { entries: true, imports: false },
422+
output: { path: path.join(root, "dist"), filename: "main.js" },
423+
plugins: [
424+
new CopyRspackPlugin({ patterns: [{ from: "assets", to: "copied" }] })
425+
]
426+
});
427+
const middleware = lazyCompilationMiddleware(compiler);
428+
const watching = watch(compiler);
429+
430+
try {
431+
const initial = await watching.next();
432+
expect(initial.error).toBeNull();
433+
expect(initial.stats.hasErrors()).toBe(false);
434+
expect(
435+
initial.stats.compilation
436+
.getAssets()
437+
.filter(({ name }) => name.startsWith("copied/"))
438+
).toHaveLength(fileCount);
439+
await new Promise(resolve => setTimeout(resolve, 200));
440+
441+
const bundle = fs.readFileSync(path.join(root, "dist/main.js"), "utf8");
442+
const encoded = bundle.match(/var data = ("(?:[^"\\]|\\.)*")/)?.[1];
443+
expect(encoded).toBeDefined();
444+
const moduleId = JSON.parse(encoded);
445+
446+
await new Promise((resolve, reject) => {
447+
Promise.resolve(
448+
middleware(
449+
{
450+
body: [moduleId],
451+
method: "POST",
452+
url: "/_rspack/lazy/trigger"
453+
},
454+
{
455+
end: resolve,
456+
write() {},
457+
writeHead(status) {
458+
expect(status).toBe(200);
459+
}
460+
},
461+
reject
462+
)
463+
).catch(reject);
464+
});
465+
const updated = await watching.next();
466+
467+
expect(updated.error).toBeNull();
468+
expect(updated.stats.hasErrors()).toBe(false);
469+
expect(updated.stats.compilation.watchInvalidationKind).toBe("lazy");
470+
expect(compiler.modifiedFiles).toEqual(new Set());
471+
expect(compiler.removedFiles).toEqual(new Set());
472+
expect(reusedPatterns(updated.stats.compilation)).toBe(1);
473+
expect(
474+
updated.stats.compilation
475+
.getAssets()
476+
.filter(({ name }) => name.startsWith("copied/"))
477+
).toHaveLength(fileCount);
478+
} finally {
479+
await watching.close();
480+
}
481+
});
482+
483+
it("does not reuse a copy pattern for an empty normal watch invalidation", async () => {
484+
const { root, compiler } = createCompiler("normal-watch-invalidation", [
485+
{ from: "assets/source", to: "copied" }
486+
]);
487+
const source = write(root, "assets/source/one.txt", "before\n");
488+
const beforeWatch = new Date(Date.now() - 3000);
489+
for (const filename of [
490+
path.join(root, "src"),
491+
path.join(root, "src/index.js"),
492+
path.join(root, "assets"),
493+
path.join(root, "assets/source"),
494+
source
495+
]) {
496+
fs.utimesSync(filename, beforeWatch, beforeWatch);
497+
}
498+
let mutateDuringWatchRun = false;
499+
compiler.hooks.watchRun.tap("copy-plugin-cache-test", () => {
500+
if (!mutateDuringWatchRun) return;
501+
mutateDuringWatchRun = false;
502+
const { atime, mtime } = fs.statSync(source);
503+
write(root, "assets/source/one.txt", "after\n");
504+
fs.utimesSync(source, atime, mtime);
505+
});
506+
const watching = watch(compiler);
507+
508+
try {
509+
const initial = await watching.next();
510+
expect(initial.error).toBeNull();
511+
expect(initial.stats.hasErrors()).toBe(false);
512+
expect(asset(initial.stats.compilation, "copied/one.txt")).toBe("before\n");
513+
await new Promise(resolve => setTimeout(resolve, 200));
514+
515+
mutateDuringWatchRun = true;
516+
watching.invalidate();
517+
const updated = await watching.next();
518+
519+
expect(updated.error).toBeNull();
520+
expect(updated.stats.hasErrors()).toBe(false);
521+
expect(updated.stats.compilation.watchInvalidationKind).toBe("normal");
522+
expect(compiler.modifiedFiles).toEqual(new Set());
523+
expect(compiler.removedFiles).toEqual(new Set());
524+
expect(asset(updated.stats.compilation, "copied/one.txt")).toBe("after\n");
525+
expect(reusedPatterns(updated.stats.compilation)).toBe(0);
526+
} finally {
527+
await watching.close();
528+
}
529+
});
530+
387531
it("preserves equal-priority copy order with interleaved cache hits and misses", async () => {
388532
const patternCount = 64;
389533
const patterns = Array.from({ length: patternCount }, (_, index) => ({

0 commit comments

Comments
 (0)