From d138a89b314f45cf21115d64e828c79d937ccfba Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:46:27 +0000 Subject: [PATCH 01/29] perf(core): reuse chunk graph with stable async outgoings --- .../artifacts/build_chunk_graph_artifact.rs | 205 ++++++++++++------ .../build_chunk_graph/code_splitter.rs | 3 +- .../async-block-outgoings/0/index.js | 3 + .../async-block-outgoings/0/lazy.js | 1 + .../async-block-outgoings/0/leaf.js | 3 + .../async-block-outgoings/0/route.js | 7 + .../async-block-outgoings/0/side-effect.js | 1 + .../async-block-outgoings/0/sync-first.js | 1 + .../async-block-outgoings/0/sync-second.js | 1 + .../async-block-outgoings/0/terminal.js | 1 + .../async-block-outgoings/1/leaf.js | 3 + .../async-block-outgoings/2/route.js | 7 + .../async-block-outgoings/rspack.config.js | 15 ++ .../async-block-outgoings/test.config.js | 23 ++ 14 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/lazy.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/leaf.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/terminal.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/1/leaf.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js diff --git a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index c2984ceebab6..f33582ccf174 100644 --- a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs +++ b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs @@ -8,8 +8,8 @@ use rustc_hash::FxHashMap as HashMap; use tracing::instrument; use crate::{ - ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation, - Logger, ModuleIdentifier, + ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupKind, ChunkGroupUkey, + ChunkUkey, Compilation, DependenciesBlock, GroupOptions, Logger, build_chunk_graph::code_splitter::{CodeSplitter, DependenciesBlockIdentifier}, fast_set, incremental::{IncrementalPasses, Mutation}, @@ -86,26 +86,113 @@ impl BuildChunkGraphArtifact { } for module in affected_modules { - let outgoings: Vec = { - let mut res = vec![]; - let mut active_modules = IdentifierIndexMap::>::default(); - let module = module_graph - .module_graph_module_by_identifier(&module) - .expect("should have module"); - module - .all_dependencies() - .iter() - .filter(|dep_id| { - module_graph - .dependency_by_id(dep_id) - .as_module_dependency() - .is_none_or(|module_dep| !module_dep.weak()) - }) - .filter_map(|dep| module_graph.connection_by_dependency_id(dep)) - .for_each(|conn| { - let m = *conn.module_identifier(); - active_modules.entry(m).or_default().push(conn); + let module_graph_module = module_graph + .module_graph_module_by_identifier(&module) + .expect("should have module"); + let current_blocks = module_graph + .module_by_identifier(&module) + .expect("should have module") + .get_blocks(); + let previous_blocks = self + .code_splitter + .prepared_blocks_map + .get(&DependenciesBlockIdentifier::Module(module)) + .map(Vec::as_slice) + .unwrap_or_default(); + + if current_blocks != previous_blocks { + logger.log(format!("module async blocks change detected: {module}")); + return false; + } + + for block_id in current_blocks { + let block = module_graph.block_by_id_expect(block_id); + // Nested async blocks are not currently constructed, but avoid + // reusing an incomplete topology if support is added later. + if !block.get_blocks().is_empty() { + logger.log(format!("nested async blocks detected: {module}")); + return false; + } + + let Some(previous_chunk_group) = self + .chunk_graph + .get_block_chunk_group(block_id, &self.chunk_group_by_ukey) + else { + continue; + }; + let same_group_options = match (block.get_group_options(), &previous_chunk_group.kind) { + (None, ChunkGroupKind::Normal { options }) => options == &Default::default(), + (Some(GroupOptions::ChunkGroup(current)), ChunkGroupKind::Normal { options }) => { + current == options + } + (Some(GroupOptions::Entrypoint(current)), ChunkGroupKind::Entrypoint { options, .. }) => { + current == options + } + _ => false, + }; + + if !same_group_options { + logger.log(format!( + "module async block options change detected: {module}" + )); + return false; + } + } + + // Match CodeSplitter::prepare: ESM dependencies are ordered by source + // order and unordered dependencies are appended afterwards. Keep root + // and async-block connections separate; all_dependencies contains both. + let mut ordered_dependencies = vec![]; + let mut unordered_dependencies = vec![]; + for dep_id in module_graph_module.all_dependencies() { + let dependency = module_graph.dependency_by_id(dep_id); + let module_dependency = dependency.as_module_dependency(); + if (module_dependency.is_none() && dependency.as_context_dependency().is_none()) + || module_dependency.is_some_and(|module_dep| module_dep.weak()) + || module_graph.connection_by_dependency_id(dep_id).is_none() + { + continue; + } + + if let Some(source_order) = dependency.source_order() { + ordered_dependencies.push((source_order, *dep_id)); + } else { + unordered_dependencies.push(*dep_id); + } + } + ordered_dependencies.sort_by_key(|(source_order, _)| *source_order); + + let mut active_modules_by_block = + HashMap::>>::default(); + for dep_id in ordered_dependencies + .into_iter() + .map(|(_, dep_id)| dep_id) + .chain(unordered_dependencies) + { + let block = module_graph + .get_parent_block(&dep_id) + .map_or(DependenciesBlockIdentifier::Module(module), |block| { + DependenciesBlockIdentifier::AsyncDependenciesBlock(*block) }); + let connection = module_graph + .connection_by_dependency_id(&dep_id) + .expect("should have connection"); + active_modules_by_block + .entry(block) + .or_default() + .entry(*connection.module_identifier()) + .or_default() + .push(connection); + } + + for block in std::iter::once(DependenciesBlockIdentifier::Module(module)).chain( + current_blocks + .iter() + .copied() + .map(DependenciesBlockIdentifier::AsyncDependenciesBlock), + ) { + let mut outgoings = vec![]; + let active_modules = active_modules_by_block.remove(&block).unwrap_or_default(); 'outer: for (m, connections) in active_modules { let side_effects_state_artifact = &this_compilation @@ -122,56 +209,48 @@ impl BuildChunkGraphArtifact { ) .is_not_false() { - res.push(m); + outgoings.push(m); continue 'outer; } } } - res - }; - - // get outgoings from all runtimes in the previous compilation - let mut previous_modules = IdentifierIndexMap::default(); - let all_runtimes = previous_modules_map.values(); - let mut miss_in_previous = true; - - all_runtimes.for_each(|modules| { - let Some(outgoings) = modules.get(&DependenciesBlockIdentifier::Module(module)) else { - return; - }; - miss_in_previous = false; - - for (outgoing, state, _) in outgoings.iter() { - // we must insert module even if state is false - // because we need to keep the import order - previous_modules - .entry(*outgoing) - .and_modify(|v| { - if state.is_not_false() { - *v = *state; - } - }) - .or_insert(*state); + let mut previous_modules = IdentifierIndexMap::default(); + let mut miss_in_previous = true; + for modules in previous_modules_map.values() { + let Some(outgoings) = modules.get(&block) else { + continue; + }; + miss_in_previous = false; + + for (outgoing, state, _) in outgoings.iter() { + // Keep false connections to preserve source order. + previous_modules + .entry(*outgoing) + .and_modify(|v| { + if state.is_not_false() { + *v = *state; + } + }) + .or_insert(*state); + } } - }); - if miss_in_previous { - logger.log("new module detected, rebuilding chunk graph"); - return false; - } + if miss_in_previous { + logger.log("new module detected, rebuilding chunk graph"); + return false; + } - if previous_modules - .iter() - .filter(|(_, conn_state)| conn_state.is_not_false()) - .map(|(m, _)| *m) - .collect::>() - != outgoings.clone() - { - // we find one module's outgoings has changed - // we cannot skip rebuilding - logger.log(format!("module outgoings change detected: {module}")); - return false; + if previous_modules + .iter() + .filter(|(_, conn_state)| conn_state.is_not_false()) + .map(|(m, _)| *m) + .collect::>() + != outgoings + { + logger.log(format!("module outgoings change detected: {module}")); + return false; + } } } diff --git a/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs b/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs index 6ad4452efc8f..306df97d24e8 100644 --- a/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs +++ b/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs @@ -307,7 +307,8 @@ pub(crate) struct CodeSplitter { prepared_connection_map: IdentifierMap, - prepared_blocks_map: DependenciesBlockIdentifierMap>, + pub(crate) prepared_blocks_map: + DependenciesBlockIdentifierMap>, } fn add_chunk_in_group( diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js new file mode 100644 index 000000000000..e84f295dd5bb --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js @@ -0,0 +1,3 @@ +import { value, load, sync } from "./route"; + +export { value, load, sync }; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/lazy.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/lazy.js new file mode 100644 index 000000000000..78cd2d7296ea --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/lazy.js @@ -0,0 +1 @@ +export const lazy = true; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/leaf.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/leaf.js new file mode 100644 index 000000000000..6075e7413709 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/leaf.js @@ -0,0 +1,3 @@ +import { suffix } from "./terminal"; + +export const value = `before:${suffix}`; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js new file mode 100644 index 000000000000..4c98c12b4762 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js @@ -0,0 +1,7 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +export const sync = `${first}:${second}`; +export const load = () => import(/* webpackChunkName: "lazy" */ "./lazy"); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js new file mode 100644 index 000000000000..9bc50ef8a8af --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js @@ -0,0 +1 @@ +globalThis.__codesplit_side_effect__ = true; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js new file mode 100644 index 000000000000..bdcc77fd51a6 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js @@ -0,0 +1 @@ +export const first = "first"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js new file mode 100644 index 000000000000..5f31aa29deed --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js @@ -0,0 +1 @@ +export const second = "second"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/terminal.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/terminal.js new file mode 100644 index 000000000000..aaa1786d5130 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/terminal.js @@ -0,0 +1 @@ +export const suffix = "stable"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/1/leaf.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/1/leaf.js new file mode 100644 index 000000000000..9c67508d9dc7 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/1/leaf.js @@ -0,0 +1,3 @@ +import { suffix } from "./terminal"; + +export const value = `after:${suffix}`; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js new file mode 100644 index 000000000000..af62a85a7a25 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js @@ -0,0 +1,7 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +export const sync = `${first}:${second}`; +export const load = () => import(/* webpackChunkName: "next" */ "./lazy"); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js new file mode 100644 index 000000000000..45607ab85a2f --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js @@ -0,0 +1,15 @@ +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + optimization: { + splitChunks: false, + sideEffects: false, + }, + incremental: { + buildChunkGraph: true, + }, + stats: { + preset: "verbose", + logging: "verbose", + loggingDebug: [/codeSplittingCache/, /incremental/], + }, +}; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js new file mode 100644 index 000000000000..d8f6d0df54af --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js @@ -0,0 +1,23 @@ +function assert(condition, message) { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +module.exports = { + checkStats(stepName, _, stats) { + const rebuild = stats.includes(" rebuild chunk graph"); + if (stepName === "0") { + assert(rebuild, "cold build must build the chunk graph"); + } else if (stepName === "1") { + assert(!rebuild, "editing the leaf must reuse stable sync and async topology"); + assert( + !stats.includes("module outgoings change detected"), + "root and async-block outgoings must be compared independently" + ); + } else if (stepName === "2") { + assert(rebuild, "changing an async chunk name must rebuild the chunk graph"); + } else { + throw new Error(`Unexpected watch step: ${stepName}`); + } + return true; + }, +}; From 91cc3c8494cf25bd411e995f17f7557b5900efde Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:46:27 +0000 Subject: [PATCH 02/29] test: format async outgoing watch fixture --- .../build-chunk-graph/async-block-outgoings/rspack.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js index 45607ab85a2f..36ff918dec65 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js @@ -8,8 +8,8 @@ module.exports = { buildChunkGraph: true, }, stats: { - preset: "verbose", - logging: "verbose", + preset: 'verbose', + logging: 'verbose', loggingDebug: [/codeSplittingCache/, /incremental/], }, }; From e8740f6252c71f1f020cfe5caa8d1e925b4d447d Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:46:27 +0000 Subject: [PATCH 03/29] perf(core): tighten chunk graph reuse and release connection wrappers --- crates/rspack_binding_api/src/lib.rs | 2 ++ .../src/artifacts/build_chunk_graph_artifact.rs | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/rspack_binding_api/src/lib.rs b/crates/rspack_binding_api/src/lib.rs index a26fa1fad13b..6048afe0d734 100644 --- a/crates/rspack_binding_api/src/lib.rs +++ b/crates/rspack_binding_api/src/lib.rs @@ -131,6 +131,7 @@ use crate::{ error::{ErrorCode, RspackResultToNapiResultExt}, fs_node::{HybridFileSystem, NodeFileSystem, ThreadsafeNodeFS}, module::ModuleObject, + module_graph_connection::ModuleGraphConnectionWrapper, platform::RawCompilerPlatform, plugins::{ JsCleanupPlugin, JsHooksAdapterPlugin, RegisterJsTapKind, RegisterJsTaps, buildtime_plugins, @@ -528,6 +529,7 @@ impl JsCompiler { ChunkGroupWrapper::cleanup_last_compilation(compilation_id); DependencyWrapper::cleanup_last_compilation(compilation_id); AsyncDependenciesBlockWrapper::cleanup_last_compilation(compilation_id); + ModuleGraphConnectionWrapper::cleanup_last_compilation(compilation_id); } } diff --git a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index f33582ccf174..f7ac5f9fecee 100644 --- a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs +++ b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs @@ -144,6 +144,8 @@ impl BuildChunkGraphArtifact { // and async-block connections separate; all_dependencies contains both. let mut ordered_dependencies = vec![]; let mut unordered_dependencies = vec![]; + let mut ordered_dependencies_sorted = true; + let mut last_source_order = None; for dep_id in module_graph_module.all_dependencies() { let dependency = module_graph.dependency_by_id(dep_id); let module_dependency = dependency.as_module_dependency(); @@ -155,12 +157,20 @@ impl BuildChunkGraphArtifact { } if let Some(source_order) = dependency.source_order() { + if let Some(previous) = last_source_order + && source_order < previous + { + ordered_dependencies_sorted = false; + } + last_source_order = Some(source_order); ordered_dependencies.push((source_order, *dep_id)); } else { unordered_dependencies.push(*dep_id); } } - ordered_dependencies.sort_by_key(|(source_order, _)| *source_order); + if !ordered_dependencies_sorted { + ordered_dependencies.sort_by_key(|(source_order, _)| *source_order); + } let mut active_modules_by_block = HashMap::>>::default(); From 4710507d5c8a929b57737dd2abff24e061553702 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:47:29 +0000 Subject: [PATCH 04/29] perf(copy-plugin): reuse unchanged static copy patterns during watch --- crates/rspack_plugin_copy/src/lib.rs | 121 ++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index ac6f984b0bc4..3e750ed362cf 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -22,7 +22,7 @@ use rspack_error::{Diagnostic, Error, Result}; use rspack_hash::{HashDigest, HashFunction, HashSalt, RspackHashDigest, RspackHasher}; use rspack_hook::{plugin, plugin_hook}; use rspack_paths::{Utf8Path, Utf8PathBuf}; -use rspack_util::fx_hash::FxDashSet; +use rspack_util::fx_hash::{FxDashMap, FxDashSet}; use sugar_path::SugarPath; #[derive(Debug)] @@ -123,10 +123,35 @@ pub struct RunPatternResult { pub pattern_index: usize, } +#[derive(Debug, Clone)] +struct CachedPatternResult { + results: Vec>, + file_dependencies: Vec, + context_dependencies: Vec, +} + +impl CachedPatternResult { + fn is_invalidated(&self, compilation: &Compilation) -> bool { + compilation + .modified_files + .iter() + .chain(compilation.removed_files.iter()) + .any(|changed| { + let changed = changed.as_ref(); + self.file_dependencies.iter().any(|file| file == changed) + || self + .context_dependencies + .iter() + .any(|context| changed.starts_with(context)) + }) + } +} + #[plugin] #[derive(Debug)] pub struct CopyRspackPlugin { pub patterns: Vec, + pattern_cache: FxDashMap, } static TEMPLATE_RE: LazyLock = @@ -142,7 +167,15 @@ fn normalize_glob_path_separators(path: &str) -> Cow<'_, str> { impl CopyRspackPlugin { pub fn new(patterns: Vec) -> Self { - Self::new_inner(patterns) + Self::new_inner(patterns, Default::default()) + } + + fn is_cacheable(pattern: &CopyPattern) -> bool { + pattern.transform_fn.is_none() + && !matches!(pattern.to, Some(ToOption::Fn(_))) + && !pattern.copy_permissions.unwrap_or(false) + && !matches!(pattern.to_type, Some(ToType::Template)) + && !matches!(pattern.to, Some(ToOption::String(ref to)) if TEMPLATE_RE.is_match(to)) } fn get_content_hash( @@ -609,15 +642,81 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { let mut copied_result: Vec<(i32, RunPatternResult)> = join_all(self.patterns.iter().enumerate().map(|(index, pattern)| { - CopyRspackPlugin::run_patter( - compilation, - pattern, - index, - &file_dependencies, - &context_dependencies, - diagnostics.clone(), - &logger, - ) + let compilation = &*compilation; + let logger = &logger; + let file_dependencies = &file_dependencies; + let context_dependencies = &context_dependencies; + let diagnostics = diagnostics.clone(); + async move { + let cacheable = CopyRspackPlugin::is_cacheable(pattern); + if cacheable + && (!compilation.modified_files.is_empty() || !compilation.removed_files.is_empty()) + && let Some(cached) = self.pattern_cache.get(&index) + && !cached.is_invalidated(compilation) + { + logger.debug(format!("reusing unchanged copy pattern {index}")); + for file in &cached.file_dependencies { + file_dependencies.insert(file.clone()); + } + for context in &cached.context_dependencies { + context_dependencies.insert(context.clone()); + } + return Ok(Some(cached.results.clone())); + } + + let pattern_file_dependencies = FxDashSet::default(); + let pattern_context_dependencies = FxDashSet::default(); + let pattern_diagnostics = Arc::new(Mutex::new(Vec::new())); + let results = CopyRspackPlugin::run_patter( + compilation, + pattern, + index, + &pattern_file_dependencies, + &pattern_context_dependencies, + pattern_diagnostics.clone(), + &logger, + ) + .await?; + + let pattern_file_dependencies = pattern_file_dependencies.into_iter().collect::>(); + let pattern_context_dependencies = + pattern_context_dependencies.into_iter().collect::>(); + let pattern_diagnostics = std::mem::take( + pattern_diagnostics + .lock() + .expect("failed to obtain lock of `pattern_diagnostics`") + .deref_mut(), + ); + let has_diagnostics = !pattern_diagnostics.is_empty(); + for file in &pattern_file_dependencies { + file_dependencies.insert(file.clone()); + } + for context in &pattern_context_dependencies { + context_dependencies.insert(context.clone()); + } + diagnostics + .lock() + .expect("failed to obtain lock of `diagnostics`") + .extend(pattern_diagnostics); + + if cacheable + && !has_diagnostics + && let Some(results) = results.as_ref() + { + self.pattern_cache.insert( + index, + CachedPatternResult { + results: results.clone(), + file_dependencies: pattern_file_dependencies, + context_dependencies: pattern_context_dependencies, + }, + ); + } else { + self.pattern_cache.remove(&index); + } + + Ok(results) + } })) .await .into_iter() From 8887a86646a18db4566e4b555c831b7fdd1b5b6a Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:47:56 +0000 Subject: [PATCH 05/29] fix(cache): release compilation-scoped plugin state on rebuild --- .../src/plugin/impl_plugin_for_css_plugin.rs | 4 ++++ crates/rspack_plugin_rsdoctor/src/plugin.rs | 4 ++++ crates/rspack_plugin_sri/src/lib.rs | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/crates/rspack_plugin_css/src/plugin/impl_plugin_for_css_plugin.rs b/crates/rspack_plugin_css/src/plugin/impl_plugin_for_css_plugin.rs index 757d40dd6b2b..a085cd946329 100644 --- a/crates/rspack_plugin_css/src/plugin/impl_plugin_for_css_plugin.rs +++ b/crates/rspack_plugin_css/src/plugin/impl_plugin_for_css_plugin.rs @@ -635,4 +635,8 @@ impl Plugin for CssPlugin { Ok(()) } + + fn clear_cache(&self, id: CompilationId) { + COMPILATION_HOOKS_MAP.remove(&id); + } } diff --git a/crates/rspack_plugin_rsdoctor/src/plugin.rs b/crates/rspack_plugin_rsdoctor/src/plugin.rs index 1faf824e63be..cae558970b2b 100644 --- a/crates/rspack_plugin_rsdoctor/src/plugin.rs +++ b/crates/rspack_plugin_rsdoctor/src/plugin.rs @@ -702,5 +702,9 @@ impl Plugin for RsdoctorPlugin { fn clear_cache(&self, id: CompilationId) { COMPILATION_HOOKS_MAP.remove(&id); + MODULE_UKEY_MAP.remove(&id); + ENTRYPOINT_UKEY_MAP.remove(&id); + JSON_MODULE_SIZE_MAP.remove(&id); + ACTIVE_EXPORT_USAGE_DEPENDENCY_MAP.remove(&id); } } diff --git a/crates/rspack_plugin_sri/src/lib.rs b/crates/rspack_plugin_sri/src/lib.rs index b6a4bcb5c35b..cd6ca21a9c79 100644 --- a/crates/rspack_plugin_sri/src/lib.rs +++ b/crates/rspack_plugin_sri/src/lib.rs @@ -221,4 +221,9 @@ impl Plugin for SubresourceIntegrityPlugin { .tap(handle_runtime::new(self)); Ok(()) } + + fn clear_cache(&self, id: CompilationId) { + COMPILATION_INTEGRITY_MAP.remove(&id); + COMPILATION_CONTEXT_MAP.remove(&id); + } } From ce7ca20a62e5a8fe1f3633fba890008f0cd4ec5b Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:36:21 +0000 Subject: [PATCH 06/29] perf(core): keep stable async and existing leaf chunk graphs --- .../artifacts/build_chunk_graph_artifact.rs | 119 ++++--------- .../build_chunk_graph/code_splitter.rs | 160 +++++++++--------- .../graph_updater/repair/build.rs | 5 +- crates/rspack_core/src/dependencies_block.rs | 108 +++++++++++- .../async-block-outgoings/0/index.js | 40 ++++- .../async-block-outgoings/0/route.js | 6 +- .../async-block-outgoings/0/side-effect.js | 1 + .../async-block-outgoings/0/sync-first.js | 2 +- .../async-block-outgoings/0/sync-second.js | 2 +- .../async-block-outgoings/2/route.js | 6 +- .../async-block-outgoings/3/other.js | 1 + .../async-block-outgoings/3/route.js | 11 ++ .../async-block-outgoings/4/route.js | 14 ++ .../async-block-outgoings/5/extra.js | 1 + .../async-block-outgoings/5/route.js | 12 ++ .../async-block-outgoings/6/route.js | 11 ++ .../async-block-outgoings/7/terminal.js | 1 + .../async-block-outgoings/rspack.config.js | 4 + .../async-block-outgoings/test.config.js | 33 +++- .../pre-order-index/rspack.config.js | 5 + .../pre-order-index/test.config.js | 26 +++ .../pre-order-index/test.filter.js | 1 - 22 files changed, 389 insertions(+), 180 deletions(-) create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/other.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/4/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/extra.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/6/route.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/7/terminal.js create mode 100644 tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.config.js delete mode 100644 tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.filter.js diff --git a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index f7ac5f9fecee..aea50280fa06 100644 --- a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs +++ b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs @@ -10,7 +10,10 @@ use tracing::instrument; use crate::{ ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupKind, ChunkGroupUkey, ChunkUkey, Compilation, DependenciesBlock, GroupOptions, Logger, - build_chunk_graph::code_splitter::{CodeSplitter, DependenciesBlockIdentifier}, + build_chunk_graph::code_splitter::{ + CodeSplitter, DependenciesBlockIdentifier, get_active_state_of_connections, + prepare_module_connection_map, + }, fast_set, incremental::{IncrementalPasses, Mutation}, }; @@ -86,9 +89,6 @@ impl BuildChunkGraphArtifact { } for module in affected_modules { - let module_graph_module = module_graph - .module_graph_module_by_identifier(&module) - .expect("should have module"); let current_blocks = module_graph .module_by_identifier(&module) .expect("should have module") @@ -139,59 +139,11 @@ impl BuildChunkGraphArtifact { } } - // Match CodeSplitter::prepare: ESM dependencies are ordered by source - // order and unordered dependencies are appended afterwards. Keep root - // and async-block connections separate; all_dependencies contains both. - let mut ordered_dependencies = vec![]; - let mut unordered_dependencies = vec![]; - let mut ordered_dependencies_sorted = true; - let mut last_source_order = None; - for dep_id in module_graph_module.all_dependencies() { - let dependency = module_graph.dependency_by_id(dep_id); - let module_dependency = dependency.as_module_dependency(); - if (module_dependency.is_none() && dependency.as_context_dependency().is_none()) - || module_dependency.is_some_and(|module_dep| module_dep.weak()) - || module_graph.connection_by_dependency_id(dep_id).is_none() - { - continue; - } - - if let Some(source_order) = dependency.source_order() { - if let Some(previous) = last_source_order - && source_order < previous - { - ordered_dependencies_sorted = false; - } - last_source_order = Some(source_order); - ordered_dependencies.push((source_order, *dep_id)); - } else { - unordered_dependencies.push(*dep_id); - } - } - if !ordered_dependencies_sorted { - ordered_dependencies.sort_by_key(|(source_order, _)| *source_order); - } - - let mut active_modules_by_block = - HashMap::>>::default(); - for dep_id in ordered_dependencies - .into_iter() - .map(|(_, dep_id)| dep_id) - .chain(unordered_dependencies) - { - let block = module_graph - .get_parent_block(&dep_id) - .map_or(DependenciesBlockIdentifier::Module(module), |block| { - DependenciesBlockIdentifier::AsyncDependenciesBlock(*block) - }); - let connection = module_graph - .connection_by_dependency_id(&dep_id) - .expect("should have connection"); - active_modules_by_block - .entry(block) - .or_default() - .entry(*connection.module_identifier()) - .or_default() + let mut prepared_connections_by_block = HashMap::default(); + for connection in prepare_module_connection_map(module, module_graph).unwrap_or_default() { + prepared_connections_by_block + .entry(connection.block) + .or_insert_with(Vec::new) .push(connection); } @@ -201,29 +153,25 @@ impl BuildChunkGraphArtifact { .copied() .map(DependenciesBlockIdentifier::AsyncDependenciesBlock), ) { - let mut outgoings = vec![]; - let active_modules = active_modules_by_block.remove(&block).unwrap_or_default(); - - 'outer: for (m, connections) in active_modules { - let side_effects_state_artifact = &this_compilation - .build_module_graph_artifact - .side_effects_state_artifact; - for conn in connections { - if conn - .active_state( - module_graph, - None, - module_graph_cache, - side_effects_state_artifact, - &this_compilation.exports_info_artifact, - ) - .is_not_false() - { - outgoings.push(m); - continue 'outer; - } - } - } + let outgoings = prepared_connections_by_block + .remove(&block) + .unwrap_or_default() + .into_iter() + .filter(|connection| { + get_active_state_of_connections( + &connection.connections, + None, + module_graph, + module_graph_cache, + &this_compilation + .build_module_graph_artifact + .side_effects_state_artifact, + &this_compilation.exports_info_artifact, + ) + .is_not_false() + }) + .map(|connection| connection.module) + .collect::>(); let mut previous_modules = IdentifierIndexMap::default(); let mut miss_in_previous = true; @@ -246,17 +194,20 @@ impl BuildChunkGraphArtifact { } } - if miss_in_previous { + if miss_in_previous + && !(matches!(block, DependenciesBlockIdentifier::Module(_)) + && outgoings.is_empty() + && self.chunk_graph.try_get_module_chunks(&module).is_some()) + { logger.log("new module detected, rebuilding chunk graph"); return false; } - if previous_modules + if !previous_modules .iter() .filter(|(_, conn_state)| conn_state.is_not_false()) .map(|(m, _)| *m) - .collect::>() - != outgoings + .eq(outgoings) { logger.log(format!("module outgoings change detected: {module}")); return false; diff --git a/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs b/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs index 306df97d24e8..11c42548cd16 100644 --- a/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs +++ b/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs @@ -35,17 +35,17 @@ pub(crate) type DependenciesBlockIdentifierSet = std::collections::HashSet>; type ConnectionIdList = Arc>; -type PreparedBlockConnectionMap = Vec; +pub(crate) type PreparedBlockConnectionMap = Vec; type BlockModules = Vec<(ModuleIdentifier, ConnectionState, ConnectionIdList)>; type BlockConnectionMap = DependenciesBlockIdentifierMap>; static EMPTY_BLOCK_MODULES: LazyLock> = LazyLock::new(|| Arc::new(Vec::new())); #[derive(Debug, Clone)] -struct PreparedBlockConnection { - block: DependenciesBlockIdentifier, - module: ModuleIdentifier, - connections: ConnectionIdList, +pub(crate) struct PreparedBlockConnection { + pub(crate) block: DependenciesBlockIdentifier, + pub(crate) module: ModuleIdentifier, + pub(crate) connections: ConnectionIdList, } struct PreparedBlockConnectionBuilder { @@ -87,6 +87,76 @@ fn finalize_prepared_connection_map( groups } +pub(crate) fn prepare_module_connection_map( + module: ModuleIdentifier, + module_graph: &ModuleGraph, +) -> Option { + let all_dependencies = module_graph + .module_graph_module_by_identifier(&module) + .map(|module| module.all_dependencies()) + .unwrap_or_default(); + let dependency_count = all_dependencies.len(); + if dependency_count == 0 { + return None; + } + + let mut ordered_dependencies = Vec::new(); + let mut unordered_dependencies = Vec::with_capacity(dependency_count); + let mut ordered_dependencies_sorted = true; + let mut last_source_order = None; + for dependency_id in all_dependencies { + let dependency = module_graph.dependency_by_id(dependency_id); + let module_dependency = dependency.as_module_dependency(); + if module_dependency.is_none() && dependency.as_context_dependency().is_none() { + continue; + } + if module_dependency.is_some_and(|dependency| dependency.weak()) { + continue; + } + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + + let block = module_graph + .get_parent_block(dependency_id) + .map_or(DependenciesBlockIdentifier::Module(module), |block| { + DependenciesBlockIdentifier::AsyncDependenciesBlock(*block) + }); + let connection = PreparedBlockConnectionBuilder { + block, + module: *connection.module_identifier(), + dependency: *dependency_id, + }; + if let Some(source_order) = dependency.source_order() { + if let Some(previous) = last_source_order + && source_order < previous + { + ordered_dependencies_sorted = false; + } + last_source_order = Some(source_order); + ordered_dependencies.push((source_order, connection)); + } else { + unordered_dependencies.push(connection); + } + } + if !ordered_dependencies_sorted { + ordered_dependencies.sort_by_key(|(source_order, _)| *source_order); + } + + let connection_count = ordered_dependencies.len() + unordered_dependencies.len(); + if connection_count == 0 { + return None; + } + + Some(finalize_prepared_connection_map( + ordered_dependencies + .into_iter() + .map(|(_, connection)| connection) + .chain(unordered_dependencies), + connection_count, + )) +} + #[derive(Debug, Clone, Default)] pub struct ChunkGroupInfo { pub initialized: bool, @@ -337,7 +407,7 @@ fn add_chunk_in_group( chunk_group } -fn get_active_state_of_connections( +pub(crate) fn get_active_state_of_connections( connections: &[DependencyId], runtime: Option<&RuntimeSpec>, module_graph: &ModuleGraph, @@ -2362,83 +2432,7 @@ Or do you want to use the entrypoints '{name}' and '{runtime}' independently on let mg = compilation.get_module_graph(); self.prepared_connection_map = all_modules .par_iter() - .filter_map(|module| { - let all_dependencies = mg - .module_graph_module_by_identifier(module) - .map(|mgm| mgm.all_dependencies()) - .unwrap_or_default(); - let dependency_count = all_dependencies.len(); - if dependency_count == 0 { - return None; - } - - let mut ordered_deps = Vec::new(); - let mut unordered_deps = Vec::with_capacity(dependency_count); - let mut ordered_deps_sorted = true; - let mut last_source_order = None; - for dep_id in all_dependencies { - let dep = mg.dependency_by_id(dep_id); - let module_dep = dep.as_module_dependency(); - if module_dep.is_none() && dep.as_context_dependency().is_none() { - continue; - } - if matches!(module_dep.map(|d| d.weak()), Some(true)) { - continue; - } - let Some(connection) = mg.connection_by_dependency_id(dep_id) else { - continue; - }; - - let module_identifier = *connection.module_identifier(); - let block_id = if let Some(block) = mg.get_parent_block(dep_id) { - (*block).into() - } else { - (*module).into() - }; - if let Some(source_order) = dep.source_order() { - if let Some(last_source_order) = last_source_order - && source_order < last_source_order - { - ordered_deps_sorted = false; - } - last_source_order = Some(source_order); - ordered_deps.push((source_order, block_id, *dep_id, module_identifier)); - } else { - unordered_deps.push((block_id, *dep_id, module_identifier)); - } - } - if !ordered_deps_sorted { - ordered_deps.sort_by_key(|(source_order, _, _, _)| *source_order); - } - - let connection_count = ordered_deps.len() + unordered_deps.len(); - if connection_count == 0 { - return None; - } - let ordered_deps = ordered_deps - .into_iter() - .map( - |(_, block, dependency, module)| PreparedBlockConnectionBuilder { - block, - module, - dependency, - }, - ); - let unordered_deps = unordered_deps - .into_iter() - .map( - |(block, dependency, module)| PreparedBlockConnectionBuilder { - block, - module, - dependency, - }, - ); - - Some(( - *module, - finalize_prepared_connection_map(ordered_deps.chain(unordered_deps), connection_count), - )) - }) + .filter_map(|module| prepare_module_connection_map(*module, mg).map(|map| (*module, map))) .collect::>(); let mut prepared_blocks_map = DependenciesBlockIdentifierMap::< diff --git a/crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs b/crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs index 6e9425017071..e468d9fcede9 100644 --- a/crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs +++ b/crates/rspack_core/src/compilation/build_module_graph/graph_updater/repair/build.rs @@ -11,6 +11,7 @@ use crate::{ CompilerId, CompilerOptions, DependencyParents, ModuleCodeTemplate, ResolverFactory, SharedPluginDriver, compilation::build_module_graph::{ForwardedIdSet, HasLazyDependencies, LazyDependencies}, + dependencies_block::stabilize_async_block_identifiers, utils::{ ResourceId, task_loop::{Task, TaskResult, TaskType}, @@ -171,7 +172,9 @@ impl Task for BuildResultTask { } blocks }; - let blocks = handle_block(build_result.dependencies, build_result.blocks, None); + let mut blocks = build_result.blocks; + stabilize_async_block_identifiers(&mut blocks); + let blocks = handle_block(build_result.dependencies, blocks, None); queue.extend(blocks); while let Some(mut block) = queue.pop_front() { diff --git a/crates/rspack_core/src/dependencies_block.rs b/crates/rspack_core/src/dependencies_block.rs index 9a851de6602d..19abffcc7c89 100644 --- a/crates/rspack_core/src/dependencies_block.rs +++ b/crates/rspack_core/src/dependencies_block.rs @@ -118,9 +118,6 @@ impl AsyncDependenciesBlock { dependency_ids.push(*dep.id()); } - if let Some(loc) = loc.as_ref() { - write!(id, "|loc={loc}").expect("write to String should not fail"); - } if let Some(modifier) = modifier { id.push_str("|modifier="); id.push_str(modifier); @@ -143,6 +140,17 @@ impl AsyncDependenciesBlock { self.id } + fn set_identifier_occurrence( + &mut self, + identifier: AsyncDependenciesBlockIdentifier, + occurrence: usize, + ) { + let mut id = String::with_capacity(identifier.0.as_str().len() + "|occurrence=".len() + 20); + id.push_str(identifier.0.as_str()); + write!(id, "|occurrence={occurrence}").expect("write to String should not fail"); + self.id = id.into(); + } + pub fn set_group_options(&mut self, group_options: GroupOptions) { self.group_options = Some(group_options) } @@ -212,6 +220,29 @@ impl AsyncDependenciesBlock { } } +pub(crate) fn stabilize_async_block_identifiers(blocks: &mut [Box]) { + if blocks.len() < 2 { + return; + } + + let mut occurrences = AsyncDependenciesBlockIdentifierMap::::default(); + let mut identifiers = AsyncDependenciesBlockIdentifierSet::default(); + for block in blocks { + let identifier = block.identifier(); + let occurrence = occurrences.entry(identifier).or_default(); + loop { + if *occurrence != 0 { + block.set_identifier_occurrence(identifier, *occurrence); + } + if identifiers.insert(block.identifier()) { + break; + } + *occurrence += 1; + } + *occurrence += 1; + } +} + impl DependenciesBlock for AsyncDependenciesBlock { fn add_block_id(&mut self, _block: AsyncDependenciesBlockIdentifier) { unimplemented!("Nested block are not implemented"); @@ -248,3 +279,74 @@ impl From for rspack_error::Error { error } } + +#[cfg(test)] +mod tests { + use super::{AsyncDependenciesBlock, stabilize_async_block_identifiers}; + use crate::{DependencyLocation, SyntheticDependencyLocation}; + + fn block(request: &str, location: &str) -> Box { + Box::new(AsyncDependenciesBlock::new( + "module".into(), + Some(DependencyLocation::Synthetic( + SyntheticDependencyLocation::new(location), + )), + Some(request), + vec![], + None, + )) + } + + #[test] + fn async_block_identifier_is_stable_when_source_location_moves() { + let mut before = vec![block("same-request", "4:8-4:24")]; + let mut after = vec![block("same-request", "7:8-7:24")]; + stabilize_async_block_identifiers(&mut before); + stabilize_async_block_identifiers(&mut after); + + assert_eq!(before[0].identifier(), after[0].identifier()); + } + + #[test] + fn repeated_async_blocks_keep_distinct_identifiers() { + let mut blocks = vec![ + block("same-request", "4:8-4:24"), + block("same-request", "5:8-5:24"), + ]; + stabilize_async_block_identifiers(&mut blocks); + + assert_ne!(blocks[0].identifier(), blocks[1].identifier()); + } + + #[test] + fn unrelated_async_blocks_do_not_change_existing_identifiers() { + let mut before = vec![ + block("first-request", "4:8-4:24"), + block("second-request", "5:8-5:24"), + ]; + let mut after = vec![ + block("new-request", "4:8-4:24"), + block("first-request", "5:8-5:24"), + block("second-request", "6:8-6:24"), + ]; + stabilize_async_block_identifiers(&mut before); + stabilize_async_block_identifiers(&mut after); + + assert_eq!(before[0].identifier(), after[1].identifier()); + assert_eq!(before[1].identifier(), after[2].identifier()); + } + + #[test] + fn occurrence_suffix_in_a_request_cannot_collide_with_repeated_async_blocks() { + let mut blocks = vec![ + block("same-request", "4:8-4:24"), + block("same-request", "5:8-5:24"), + block("same-request|occurrence=1", "6:8-6:24"), + ]; + stabilize_async_block_identifiers(&mut blocks); + + assert_ne!(blocks[0].identifier(), blocks[1].identifier()); + assert_ne!(blocks[0].identifier(), blocks[2].identifier()); + assert_ne!(blocks[1].identifier(), blocks[2].identifier()); + } +} diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js index e84f295dd5bb..89c0327fb04d 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js @@ -1,3 +1,41 @@ import { value, load, sync } from "./route"; -export { value, load, sync }; +it("should preserve sync and async outgoings across rebuilds", async () => { + expect(value).toBe( + WATCH_STEP === "0" + ? "before:stable" + : WATCH_STEP === "7" + ? "after:stable-updated" + : "after:stable", + ); + expect(sync).toBe("first:second"); + expect(globalThis.__codesplit_side_effect__).toBe(true); + expect(globalThis.__codesplit_order__).toEqual([ + "side-effect", + "first", + "second", + ]); + + const modules = await load(); + const expected = + WATCH_STEP === "5" + ? ["inserted", "retargeted", true] + : [ + WATCH_STEP === "0" || WATCH_STEP === "1" || WATCH_STEP === "2" + ? true + : "retargeted", + true, + ]; + expect(modules.map(module => module.lazy)).toEqual(expected); + + const expectedChunk = + WATCH_STEP === "0" || WATCH_STEP === "1" + ? "lazy.bundle.js" + : "next.bundle.js"; + expect(__STATS__.assets.map(asset => asset.name)).toContain(expectedChunk); + if (WATCH_STEP === "5") { + expect(__STATS__.assets.map(asset => asset.name)).toContain( + "inserted.bundle.js", + ); + } +}); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js index 4c98c12b4762..3117840fbabe 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/route.js @@ -4,4 +4,8 @@ import { second } from "./sync-second"; export { value } from "./leaf"; export const sync = `${first}:${second}`; -export const load = () => import(/* webpackChunkName: "lazy" */ "./lazy"); +export const load = () => + Promise.all([ + import(/* webpackChunkName: "lazy" */ "./lazy"), + import(/* webpackChunkName: "lazy" */ "./lazy"), + ]); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js index 9bc50ef8a8af..04b6e0975cb5 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js @@ -1 +1,2 @@ globalThis.__codesplit_side_effect__ = true; +globalThis.__codesplit_order__ = ["side-effect"]; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js index bdcc77fd51a6..b01d7717db87 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js @@ -1 +1 @@ -export const first = "first"; +export const first = (globalThis.__codesplit_order__.push("first"), "first"); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js index 5f31aa29deed..7f5a637dd844 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js @@ -1 +1 @@ -export const second = "second"; +export const second = (globalThis.__codesplit_order__.push("second"), "second"); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js index af62a85a7a25..d4f66899c70d 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/route.js @@ -4,4 +4,8 @@ import { second } from "./sync-second"; export { value } from "./leaf"; export const sync = `${first}:${second}`; -export const load = () => import(/* webpackChunkName: "next" */ "./lazy"); +export const load = () => + Promise.all([ + import(/* webpackChunkName: "next" */ "./lazy"), + import(/* webpackChunkName: "next" */ "./lazy"), + ]); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/other.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/other.js new file mode 100644 index 000000000000..77c96550b376 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/other.js @@ -0,0 +1 @@ +export const lazy = "retargeted"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/route.js new file mode 100644 index 000000000000..0d433feb0494 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/3/route.js @@ -0,0 +1,11 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +export const sync = `${first}:${second}`; +export const load = () => + Promise.all([ + import(/* webpackChunkName: "next" */ "./other"), + import(/* webpackChunkName: "next" */ "./lazy"), + ]); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/4/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/4/route.js new file mode 100644 index 000000000000..643d9811aa44 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/4/route.js @@ -0,0 +1,14 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +const beforeAsyncEdges = "moved-without-changing-topology"; +export const sync = `${first}:${second}`; +export const load = () => { + void beforeAsyncEdges; + return Promise.all([ + import(/* webpackChunkName: "next" */ "./other"), + import(/* webpackChunkName: "next" */ "./lazy"), + ]); +}; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/extra.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/extra.js new file mode 100644 index 000000000000..7692c273e1ee --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/extra.js @@ -0,0 +1 @@ +export const lazy = "inserted"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/route.js new file mode 100644 index 000000000000..1cdb32a74240 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/5/route.js @@ -0,0 +1,12 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +export const sync = `${first}:${second}`; +export const load = () => + Promise.all([ + import(/* webpackChunkName: "inserted" */ "./extra"), + import(/* webpackChunkName: "next" */ "./other"), + import(/* webpackChunkName: "next" */ "./lazy"), + ]); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/6/route.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/6/route.js new file mode 100644 index 000000000000..0d433feb0494 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/6/route.js @@ -0,0 +1,11 @@ +import "./side-effect"; +import { first } from "./sync-first"; +import { second } from "./sync-second"; +export { value } from "./leaf"; + +export const sync = `${first}:${second}`; +export const load = () => + Promise.all([ + import(/* webpackChunkName: "next" */ "./other"), + import(/* webpackChunkName: "next" */ "./lazy"), + ]); diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/7/terminal.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/7/terminal.js new file mode 100644 index 000000000000..3bfb21dc5e43 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/7/terminal.js @@ -0,0 +1 @@ +export const suffix = "stable-updated"; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js index 36ff918dec65..e4e3a61a12ef 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js @@ -1,5 +1,9 @@ /** @type {import("@rspack/core").Configuration} */ module.exports = { + output: { + filename: 'bundle.js', + chunkFilename: '[name].bundle.js', + }, optimization: { splitChunks: false, sideEffects: false, diff --git a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js index d8f6d0df54af..324ec1d58262 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js @@ -8,13 +8,40 @@ module.exports = { if (stepName === "0") { assert(rebuild, "cold build must build the chunk graph"); } else if (stepName === "1") { - assert(!rebuild, "editing the leaf must reuse stable sync and async topology"); + assert( + !rebuild, + "editing the leaf must reuse stable sync and async topology", + ); assert( !stats.includes("module outgoings change detected"), - "root and async-block outgoings must be compared independently" + "root and async-block outgoings must be compared independently", ); } else if (stepName === "2") { - assert(rebuild, "changing an async chunk name must rebuild the chunk graph"); + assert( + rebuild, + "changing an async chunk name must rebuild the chunk graph", + ); + } else if (stepName === "3") { + assert(rebuild, "retargeting an async edge must rebuild the chunk graph"); + } else if (stepName === "4") { + assert(!rebuild, "moving stable async edges must reuse the chunk graph"); + assert( + !stats.includes("module async blocks change detected"), + "source-location changes must not invalidate stable async blocks", + ); + } else if (stepName === "5") { + assert(rebuild, "inserting an async edge must rebuild the chunk graph"); + } else if (stepName === "6") { + assert(rebuild, "removing an async edge must rebuild the chunk graph"); + } else if (stepName === "7") { + assert( + !rebuild, + "editing a module with no outgoing edges must reuse the chunk graph", + ); + assert( + !stats.includes("new module detected"), + "an existing terminal module must not be treated as a new module", + ); } else { throw new Error(`Unexpected watch step: ${stepName}`); } diff --git a/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/rspack.config.js b/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/rspack.config.js index 94fa3fe97b57..b65d585ae6b5 100644 --- a/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/rspack.config.js +++ b/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/rspack.config.js @@ -6,4 +6,9 @@ module.exports = { incremental: { buildChunkGraph: true, }, + stats: { + preset: 'verbose', + logging: 'verbose', + loggingDebug: [/codeSplittingCache/, /incremental/], + }, }; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.config.js b/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.config.js new file mode 100644 index 000000000000..c425db081c10 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.config.js @@ -0,0 +1,26 @@ +function assert(condition, message) { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +module.exports = { + checkStats(stepName, _, stats) { + if (stepName === "0") { + assert( + stats.includes(" rebuild chunk graph"), + "cold build must build the chunk graph", + ); + } else if (stepName === "1") { + assert( + !stats.includes(" rebuild chunk graph"), + "editing an existing leaf must reuse the chunk graph", + ); + assert( + !stats.includes("new module detected"), + "an existing leaf must not be treated as a new module", + ); + } else { + throw new Error(`Unexpected watch step: ${stepName}`); + } + return true; + }, +}; diff --git a/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.filter.js b/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.filter.js deleted file mode 100644 index e02024a19819..000000000000 --- a/tests/rspack-test/watchCases/build-chunk-graph/pre-order-index/test.filter.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = () => "incremental.buildChunkGraph heuristic update is disabled"; From f10f433b947dab88066bd8f9ab03b5e44f8e8cba Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:36:32 +0000 Subject: [PATCH 07/29] perf(hmr): skip unchanged chunks without dropping installed updates --- crates/rspack_plugin_hmr/src/lib.rs | 79 ++++-- tests/rspack-test/HmrProcessAssets.test.js | 284 +++++++++++++++++++++ 2 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 tests/rspack-test/HmrProcessAssets.test.js diff --git a/crates/rspack_plugin_hmr/src/lib.rs b/crates/rspack_plugin_hmr/src/lib.rs index 01800f81a42a..771569ef363b 100644 --- a/crates/rspack_plugin_hmr/src/lib.rs +++ b/crates/rspack_plugin_hmr/src/lib.rs @@ -11,7 +11,8 @@ use rspack_core::{ ModuleId, ModuleIdentifier, ModuleType, NormalModuleFactoryParser, NormalModuleLoader, ParserAndGenerator, ParserOptions, PathData, Plugin, RunnerContext, RuntimeGlobals, RuntimeModule, RuntimeModuleExt, RuntimeSpec, - chunk_graph_chunk::{ChunkId, ChunkIdSet}, + chunk_graph_chunk::{ChunkId, ChunkIdMap, ChunkIdSet}, + incremental::{IncrementalPasses, Mutation}, rspack_sources::{RawStringSource, SourceExt}, }; use rspack_error::{Diagnostic, Result}; @@ -105,31 +106,44 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { .iter() .map(|(k, v)| (v.clone(), *k)) .collect(); - let mut completely_removed_modules: HashSet = Default::default(); + let current_chunk_ukeys: ChunkIdMap = compilation + .build_chunk_graph_artifact + .chunk_by_ukey + .iter() + .map(|(ukey, chunk)| (chunk.expect_id().clone(), *ukey)) + .collect(); + let completely_removed_modules: HashSet = old_all_modules + .iter() + .filter(|(module_id, chunks)| !chunks.is_empty() && !all_module_ids.contains_key(*module_id)) + .map(|(module_id, _)| module_id.clone()) + .collect(); + let changed_chunks = compilation + .incremental + .mutations_read(IncrementalPasses::CHUNK_ASSET) + .map(|mutations| { + mutations + .iter() + .filter_map(|mutation| match mutation { + Mutation::ChunkSetHashes { chunk } => Some(*chunk), + _ => None, + }) + .collect::>() + }); for (chunk_id, (old_runtime, old_module_ids)) in old_chunks { - let mut remaining_modules: HashSet = Default::default(); - for old_module_id in old_module_ids { - if !all_module_ids.contains_key(old_module_id) { - completely_removed_modules.insert(old_module_id.clone()); - } else { - remaining_modules.insert(old_module_id.clone()); - } - } - let mut new_modules = vec![]; let mut new_runtime_modules = vec![]; let chunk_id = chunk_id.clone(); let new_runtime: RuntimeSpec; let removed_from_runtime: RuntimeSpec; - let current_chunk = compilation - .build_chunk_graph_artifact - .chunk_by_ukey - .iter() - .find(|(_, chunk)| chunk.expect_id().eq(&chunk_id)) - .map(|(_, chunk)| chunk); - let current_chunk_ukey = current_chunk.map(|c| c.ukey()); + let current_chunk_ukey = current_chunk_ukeys.get(&chunk_id).copied(); + let current_chunk = current_chunk_ukey.and_then(|ukey| { + compilation + .build_chunk_graph_artifact + .chunk_by_ukey + .get(&ukey) + }); if let Some(current_chunk) = current_chunk { new_runtime = current_chunk @@ -142,6 +156,14 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { continue; } + if old_runtime == &new_runtime + && changed_chunks + .as_ref() + .is_some_and(|chunks| !chunks.contains(¤t_chunk.ukey())) + { + continue; + } + new_modules = compilation .build_chunk_graph_artifact .chunk_graph @@ -183,12 +205,23 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { } } - for old_module_id in remaining_modules { - let module_identifier = all_module_ids - .get(&old_module_id) - .expect("should have module"); + for old_module_id in old_module_ids { + let Some(module_identifier) = all_module_ids.get(old_module_id) else { + continue; + }; + if removed_from_runtime.is_empty() + && current_chunk_ukey.is_some_and(|ukey| { + compilation + .build_chunk_graph_artifact + .chunk_graph + .is_module_in_chunk(module_identifier, ukey) + }) + { + continue; + } + let old_hashes = old_all_modules - .get(&old_module_id) + .get(old_module_id) .expect("should have module"); let old_hash = old_hashes.get(&chunk_id); let runtimes = compilation diff --git a/tests/rspack-test/HmrProcessAssets.test.js b/tests/rspack-test/HmrProcessAssets.test.js new file mode 100644 index 000000000000..0689aeb495f0 --- /dev/null +++ b/tests/rspack-test/HmrProcessAssets.test.js @@ -0,0 +1,284 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { rspack } = require("@rspack/core"); + +const fixtureRoot = path.resolve(__dirname, "js/hmr-process-assets"); + +function write(root, filename, content) { + const file = path.join(root, filename); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function createCompiler( + name, + files, + optimization = { splitChunks: false }, + incremental = true +) { + const root = path.join(fixtureRoot, name); + fs.rmSync(root, { recursive: true, force: true }); + for (const [filename, content] of Object.entries(files)) { + write(root, filename, content); + } + + return { + root, + compiler: rspack({ + context: root, + mode: "development", + target: "web", + devtool: false, + cache: true, + incremental, + entry: { a: "./src/a.js", b: "./src/b.js" }, + output: { + path: path.join(root, "dist"), + filename: "[name].js", + chunkFilename: "[name].js" + }, + optimization, + plugins: [new rspack.HotModuleReplacementPlugin()] + }) + }; +} + +function run(compiler) { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats.hasErrors()) { + return reject(new Error(stats.toString({ all: false, errors: true }))); + } + resolve(stats.compilation); + }); + }); +} + +function rebuild(compiler, modifiedFiles) { + return new Promise((resolve, reject) => { + compiler.__internal__rebuild(new Set(modifiedFiles), new Set(), error => { + if (error) return reject(error); + const compilation = compiler._lastCompilation; + if (compilation.errors.length > 0) { + return reject(new Error(compilation.errors.join("\n"))); + } + resolve(compilation); + }); + }); +} + +function hotAssets(compilation) { + return compilation + .getAssets() + .filter(({ name }) => name.includes("hot-update")); +} + +function hotManifests(compilation) { + return hotAssets(compilation) + .filter(({ name }) => name.endsWith(".json")) + .map(({ name, source }) => ({ + name, + ...JSON.parse(source.source().toString()) + })); +} + +async function close(compiler) { + await new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); +} + +describe("HotModuleReplacementPlugin process assets", () => { + afterAll(() => { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it("emits a changed shared module for every stable runtime", async () => { + const entry = name => + `import { value } from './leaf'; globalThis.${name} = value; if (module.hot) module.hot.accept('./leaf');\n`; + const { root, compiler } = createCompiler("stable-runtimes", { + "src/a.js": entry("a"), + "src/b.js": entry("b"), + "src/leaf.js": "export const value = 'before';\n" + }); + + try { + await run(compiler); + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + const manifests = hotManifests(updated); + const hotJavaScript = hotAssets(updated).filter(({ name }) => + name.endsWith(".js") + ); + + expect(manifests).toHaveLength(2); + expect(manifests.every(({ c }) => c.length > 0)).toBe(true); + expect( + manifests.every(({ r, m }) => r.length === 0 && m.length === 0) + ).toBe(true); + expect(hotJavaScript).toHaveLength(2); + expect( + hotJavaScript.every(({ source }) => + source.source().toString().includes("after") + ) + ).toBe(true); + } finally { + await close(compiler); + } + }); + + it("preserves a shared async chunk until its final runtime is removed", async () => { + const entry = (name, includeShared) => + `${ + includeShared + ? `import(/* webpackChunkName: 'shared' */ './shared').then(({ value }) => { globalThis.${name} = value; });` + : `globalThis.${name} = 'removed';` + }\nif (module.hot) module.hot.accept();\n`; + const { root, compiler } = createCompiler("removed-runtime", { + "src/a.js": entry("a", true), + "src/b.js": entry("b", true), + "src/shared.js": "export const value = 'shared';\n" + }); + + try { + await run(compiler); + const a = write(root, "src/a.js", entry("a", false)); + const first = await rebuild(compiler, [a]); + const firstManifests = hotManifests(first); + const removedManifest = firstManifests.find(({ name }) => + name.startsWith("a.") + ); + const activeManifest = firstManifests.find(({ name }) => + name.startsWith("b.") + ); + expect(firstManifests).toHaveLength(2); + expect(first.getAsset("shared.js")).toBeDefined(); + expect(removedManifest).toBeDefined(); + expect(removedManifest.r).toContain("shared"); + expect(removedManifest.m).toContain("./src/shared.js"); + expect(activeManifest).toBeDefined(); + expect(activeManifest.r).not.toContain("shared"); + expect(activeManifest.m).not.toContain("./src/shared.js"); + + const b = write(root, "src/b.js", entry("b", false)); + const last = await rebuild(compiler, [b]); + const manifests = hotManifests(last); + + expect(last.getAsset("shared.js")).toBeUndefined(); + expect(manifests.some(({ r }) => r.includes("shared"))).toBe(true); + expect(manifests.some(({ m }) => m.includes("./src/shared.js"))).toBe( + true + ); + } finally { + await close(compiler); + } + }); + + it("updates an installed chunk when an edited module moves within a stable runtime", async () => { + const entry = (name, includeMoving) => + `import { anchor } from './shared/anchor';\n${ + includeMoving ? "import { moving } from './shared/moving';\n" : "" + }globalThis.${name} = anchor${includeMoving ? " + moving" : ""};\nif (module.hot) module.hot.accept();\n`; + const { root, compiler } = createCompiler( + "moved-module", + { + "src/a.js": entry("a", true), + "src/b.js": entry("b", true), + "src/shared/anchor.js": "export const anchor = 'anchor';\n", + "src/shared/moving.js": "export const moving = 'moving-before';\n" + }, + { + runtimeChunk: "single", + splitChunks: { + chunks: "all", + minSize: 0, + cacheGroups: { + shared: { + test: /[\\/]shared[\\/]/, + name: "shared", + minChunks: 2, + enforce: true + } + } + } + } + ); + + try { + const initial = await run(compiler); + expect( + initial.getAsset("shared.js").source.source().toString() + ).toContain("moving-before"); + + const b = write(root, "src/b.js", entry("b", false)); + const moving = write( + root, + "src/shared/moving.js", + "export const moving = 'moving-after';\n" + ); + const updated = await rebuild(compiler, [b, moving]); + expect(updated.getAsset("a.js").source.source().toString()).toContain( + "moving-after" + ); + expect( + updated.getAsset("shared.js").source.source().toString() + ).not.toContain("moving-after"); + + const hotWithMarker = hotAssets(updated) + .filter( + ({ name, source }) => + name.endsWith(".js") && + source.source().toString().includes("moving-after") + ) + .map(({ name }) => name); + expect(hotWithMarker.some(name => name.startsWith("a."))).toBe(true); + expect(hotWithMarker.some(name => name.startsWith("shared."))).toBe(true); + } finally { + await close(compiler); + } + }); + + it("falls back to a full HMR scan when incremental chunk assets are disabled", async () => { + const entry = name => + `import { value } from './leaf'; globalThis.${name} = value; if (module.hot) module.hot.accept('./leaf');\n`; + const { root, compiler } = createCompiler( + "chunk-asset-disabled", + { + "src/a.js": entry("a"), + "src/b.js": entry("b"), + "src/leaf.js": "export const value = 'before';\n" + }, + { splitChunks: false }, + { chunkAsset: false } + ); + + try { + await run(compiler); + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + const hotJavaScript = hotAssets(updated).filter(({ name }) => + name.endsWith(".js") + ); + + expect(hotManifests(updated)).toHaveLength(2); + expect(hotJavaScript).toHaveLength(2); + expect( + hotJavaScript.every(({ source }) => + source.source().toString().includes("after") + ) + ).toBe(true); + } finally { + await close(compiler); + } + }); +}); From 0aabc0559a5f3b6e9ea7dc21f9e0169d29be8b07 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:33 +0000 Subject: [PATCH 08/29] fix(watch): preserve recursive contexts and copy invalidation --- crates/rspack_plugin_copy/src/lib.rs | 85 +---- .../rspack_plugin_copy/src/pattern_cache.rs | 65 ++++ .../src/analyzer/directories.rs | 105 ++++-- crates/rspack_watcher/src/disk_watcher.rs | 206 ++++++++++- crates/rspack_watcher/tests/watcher.rs | 30 ++ tests/rspack-test/CopyPluginCache.test.js | 338 ++++++++++++++++++ .../rspack.config.js | 10 +- 7 files changed, 733 insertions(+), 106 deletions(-) create mode 100644 crates/rspack_plugin_copy/src/pattern_cache.rs create mode 100644 tests/rspack-test/CopyPluginCache.test.js diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index 3e750ed362cf..90151dcddc37 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -15,8 +15,9 @@ use regex::Regex; use rspack_core::{ AssetInfo, AssetInfoRelated, Compilation, CompilationAsset, CompilationLogger, CompilationProcessAssets, Filename, GlobMatchOptions, Logger, PathData, Plugin, - escape_glob_pattern, find_files_by_glob, + escape_glob_pattern, extract_glob_base_dir, find_files_by_glob, rspack_sources::{BoxSource, RawBufferSource, SourceExt}, + unescape_glob_path, }; use rspack_error::{Diagnostic, Error, Result}; use rspack_hash::{HashDigest, HashFunction, HashSalt, RspackHashDigest, RspackHasher}; @@ -25,6 +26,10 @@ use rspack_paths::{Utf8Path, Utf8PathBuf}; use rspack_util::fx_hash::{FxDashMap, FxDashSet}; use sugar_path::SugarPath; +mod pattern_cache; + +use pattern_cache::CachedPatternResult; + #[derive(Debug)] pub struct CopyRspackPluginOptions { pub patterns: Vec, @@ -123,30 +128,6 @@ pub struct RunPatternResult { pub pattern_index: usize, } -#[derive(Debug, Clone)] -struct CachedPatternResult { - results: Vec>, - file_dependencies: Vec, - context_dependencies: Vec, -} - -impl CachedPatternResult { - fn is_invalidated(&self, compilation: &Compilation) -> bool { - compilation - .modified_files - .iter() - .chain(compilation.removed_files.iter()) - .any(|changed| { - let changed = changed.as_ref(); - self.file_dependencies.iter().any(|file| file == changed) - || self - .context_dependencies - .iter() - .any(|context| changed.starts_with(context)) - }) - } -} - #[plugin] #[derive(Debug)] pub struct CopyRspackPlugin { @@ -446,11 +427,6 @@ impl CopyRspackPlugin { // Enable copy files starts with dot let mut dot_enable = pattern.glob_options.dot; - /* - * If input is a glob query like `/a/b/**/*.js`, we need to add common directory - * to context_dependencies - */ - let mut need_add_context_to_dependency = false; let glob_query = match from_type { FromType::Dir => { logger.debug(format!("added '{abs_from}' as a context dependency")); @@ -483,7 +459,6 @@ impl CopyRspackPlugin { escape_glob_pattern(&from) } FromType::Glob => { - need_add_context_to_dependency = true; let mut glob_query = if Path::new(orig_from).is_absolute() { orig_from.into() } else { @@ -502,6 +477,11 @@ impl CopyRspackPlugin { } }; + if matches!(from_type, FromType::Glob) { + let glob_base_dir = unescape_glob_path(extract_glob_base_dir(&glob_query)); + context_dependencies.insert(PathBuf::from(glob_base_dir).normalize().into_owned()); + } + logger.log(format!("begin globbing '{glob_query}'...")); let glob_match_options = GlobMatchOptions { @@ -532,18 +512,6 @@ impl CopyRspackPlugin { }) .collect(); - if need_add_context_to_dependency - && let Some(common_dir) = get_closest_common_parent_dir( - &entries.iter().map(|it| it.as_path()).collect::>(), - ) - { - // The glob common dir is derived from glob-matched entries, which keep - // the pattern's raw shape (e.g. a leading `./`, and `/` separators on - // Windows). Normalize it so the registered context dependency matches - // the native, normalized paths used everywhere else in the dep graph. - context_dependencies.insert(common_dir.into_std_path_buf().normalize().into_owned()); - } - if entries.is_empty() { if pattern.no_error_on_missing { logger.log( @@ -650,9 +618,14 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { async move { let cacheable = CopyRspackPlugin::is_cacheable(pattern); if cacheable - && (!compilation.modified_files.is_empty() || !compilation.removed_files.is_empty()) && let Some(cached) = self.pattern_cache.get(&index) - && !cached.is_invalidated(compilation) + && !cached.is_invalidated( + compilation + .modified_files + .iter() + .chain(compilation.removed_files.iter()) + .map(|changed| changed.as_ref()), + ) { logger.debug(format!("reusing unchanged copy pattern {index}")); for file in &cached.file_dependencies { @@ -674,7 +647,7 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { &pattern_file_dependencies, &pattern_context_dependencies, pattern_diagnostics.clone(), - &logger, + logger, ) .await?; @@ -842,26 +815,6 @@ impl Plugin for CopyRspackPlugin { } } -fn get_closest_common_parent_dir(paths: &[&Utf8Path]) -> Option { - // If there are no matching files, return `None`. - if paths.is_empty() { - return None; - } - - // Get the first file path and use it as the initial value for the common parent directory. - let mut parent_dir: Utf8PathBuf = paths[0].parent()?.to_path_buf(); - - // Iterate over the remaining file paths, updating the common parent directory as necessary. - for path in paths.iter().skip(1) { - // Find the common parent directory between the current file path and the previous common parent directory. - while !path.starts_with(&parent_dir) { - parent_dir = parent_dir.parent()?.into(); - } - } - - Some(parent_dir) -} - fn set_info(target: &mut AssetInfo, info: Info) { if let Some(minimized) = info.minimized { target.minimized.replace(minimized); diff --git a/crates/rspack_plugin_copy/src/pattern_cache.rs b/crates/rspack_plugin_copy/src/pattern_cache.rs new file mode 100644 index 000000000000..8cf4ff36c7ae --- /dev/null +++ b/crates/rspack_plugin_copy/src/pattern_cache.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use crate::RunPatternResult; + +#[derive(Debug, Clone)] +pub(super) struct CachedPatternResult { + pub(super) results: Vec>, + pub(super) file_dependencies: Vec, + pub(super) context_dependencies: Vec, +} + +impl CachedPatternResult { + pub(super) fn is_invalidated<'a>(&self, changed_paths: impl Iterator) -> bool { + let mut changed_paths = changed_paths.peekable(); + if changed_paths.peek().is_none() { + return true; + } + + changed_paths.any(|changed| { + self + .file_dependencies + .iter() + .chain(&self.context_dependencies) + .any(|dependency| changed.starts_with(dependency) || dependency.starts_with(changed)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cached(file_dependencies: &[&str], context_dependencies: &[&str]) -> CachedPatternResult { + CachedPatternResult { + results: Vec::new(), + file_dependencies: file_dependencies.iter().map(PathBuf::from).collect(), + context_dependencies: context_dependencies.iter().map(PathBuf::from).collect(), + } + } + + #[test] + fn invalidates_matching_files_and_context_descendants() { + let cached = cached(&["/project/assets/file.txt"], &["/project/assets/glob"]); + + assert!(cached.is_invalidated([Path::new("/project/assets/file.txt")].into_iter())); + assert!(cached.is_invalidated([Path::new("/project/assets/glob/nested/new.txt")].into_iter())); + } + + #[test] + fn invalidates_ancestor_directory_events_for_files_and_contexts() { + let file = cached(&["/project/assets/nested/file.txt"], &[]); + let context = cached(&[], &["/project/assets/nested"]); + + assert!(file.is_invalidated([Path::new("/project/assets")].into_iter())); + assert!(context.is_invalidated([Path::new("/project/assets")].into_iter())); + } + + #[test] + fn reuses_only_when_unrelated_watcher_provenance_exists() { + let cached = cached(&["/project/assets/file.txt"], &["/project/assets/glob"]); + + assert!(!cached.is_invalidated([Path::new("/project/src/index.js")].into_iter())); + assert!(cached.is_invalidated(std::iter::empty())); + } +} diff --git a/crates/rspack_watcher/src/analyzer/directories.rs b/crates/rspack_watcher/src/analyzer/directories.rs index a8bfd3c35404..d327bb97190b 100644 --- a/crates/rspack_watcher/src/analyzer/directories.rs +++ b/crates/rspack_watcher/src/analyzer/directories.rs @@ -1,15 +1,15 @@ #![allow(unused)] use rspack_paths::ArcPath; -use rspack_util::fx_hash::FxHashSet as HashSet; +use rspack_util::fx_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use super::{Analyzer, WatchPattern}; use crate::paths::PathAccessor; /// `WatcherDirectoriesAnalyzer` analyzes the path register and determines /// -/// which directories should be watched individually (non-recursively). -/// This is typically used on platforms where recursive watching is not -/// available or not desired, so each directory is watched separately. +/// which directories should be watched individually. File parents stay +/// non-recursive, while registered context directories must be recursive so +/// changes in pre-existing child directories are observable. #[derive(Default)] pub struct WatcherDirectoriesAnalyzer; @@ -25,25 +25,47 @@ impl Analyzer for WatcherDirectoriesAnalyzer { const DIRECTORY_WATCH_DEPTH: u32 = 2; impl WatcherDirectoriesAnalyzer { - /// Finds all directories that should be watched individually (non-recursively). + /// Finds all directories that should be watched individually, keeping the + /// strongest required mode when a file parent and context share a path. fn find_watch_directories<'a>(&self, path_accessor: PathAccessor<'a>) -> HashSet { - let mut patterns = HashSet::default(); - let all = path_accessor.all(); - for path in all { + let mut modes = HashMap::default(); + let directories = path_accessor.directories().0; + + for path in path_accessor.all() { if let Some((dir, deep)) = self.find_exists_path(path) { - // Insert the parent directory of the file - patterns.insert(WatchPattern { - path: dir, - mode: if deep >= DIRECTORY_WATCH_DEPTH { - notify::RecursiveMode::Recursive - } else { - notify::RecursiveMode::NonRecursive - }, - }); + let recursive = deep >= DIRECTORY_WATCH_DEPTH || directories.contains(&dir); + modes + .entry(dir) + .and_modify(|current| *current |= recursive) + .or_insert(recursive); } } - patterns + // A recursive root already covers its descendants. Re-registering a child + // non-recursively duplicates inotify work and can downgrade that child's mode. + let recursive_roots: HashSet = modes + .iter() + .filter_map(|(path, recursive)| recursive.then_some(path.clone())) + .collect(); + + modes + .into_iter() + .filter(|(path, _)| { + path + .as_ref() + .ancestors() + .skip(1) + .all(|parent| !recursive_roots.contains(&ArcPath::from(parent))) + }) + .map(|(path, recursive)| WatchPattern { + path, + mode: if recursive { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }, + }) + .collect() } /// Finds the deepest existing directory path and its depth. @@ -101,7 +123,7 @@ mod tests { })); assert!(watch_patterns.contains(&WatchPattern { path: ArcPath::from(current_dir.join("src")), - mode: notify::RecursiveMode::NonRecursive + mode: notify::RecursiveMode::Recursive })); } @@ -134,11 +156,7 @@ mod tests { let analyzer = WatcherDirectoriesAnalyzer::default(); let watch_patterns = analyzer.analyze(path_manager.access()); - assert_eq!(watch_patterns.len(), 3); - assert!(watch_patterns.contains(&WatchPattern { - path: dir_0.clone(), - mode: notify::RecursiveMode::NonRecursive, - })); + assert_eq!(watch_patterns.len(), 2); assert!(watch_patterns.contains(&WatchPattern { path: dir_0, mode: notify::RecursiveMode::Recursive, @@ -148,4 +166,43 @@ mod tests { mode: notify::RecursiveMode::NonRecursive, })); } + + #[test] + fn test_recursive_context_prunes_covered_file_parents() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let base = temp_dir.path().canonicalize().unwrap(); + let context_a = base.join("a"); + let context_b = base.join("b"); + let child = context_a.join("child"); + let file = child.join("file.txt"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::create_dir_all(&context_b).unwrap(); + std::fs::write(&file, "file").unwrap(); + + let path_manager = PathManager::default(); + path_manager + .update( + (std::iter::once(ArcPath::from(file)), std::iter::empty()), + ( + [ + ArcPath::from(context_a.clone()), + ArcPath::from(context_b.clone()), + ] + .into_iter(), + std::iter::empty(), + ), + (std::iter::empty(), std::iter::empty()), + ) + .unwrap(); + + let watch_patterns = WatcherDirectoriesAnalyzer.analyze(path_manager.access()); + + assert_eq!(watch_patterns.len(), 2); + for context in [context_a, context_b] { + assert!(watch_patterns.contains(&WatchPattern { + path: ArcPath::from(context), + mode: notify::RecursiveMode::Recursive, + })); + } + } } diff --git a/crates/rspack_watcher/src/disk_watcher.rs b/crates/rspack_watcher/src/disk_watcher.rs index abca79db9d0f..b8305e3d1d08 100644 --- a/crates/rspack_watcher/src/disk_watcher.rs +++ b/crates/rspack_watcher/src/disk_watcher.rs @@ -85,17 +85,20 @@ impl DiskWatcher { ) -> rspack_error::Result<()> { let new_patterns: HashSet = patterns.collect(); - let new_paths = new_patterns.iter().map(|p| &p.path).collect::>(); - - // Collect stale paths that are no longer needed, then unwatch and remove them. - let stale_paths: HashSet = self + // A changed recursive mode must be unwatched before it is registered again. + let stale_patterns: Vec<(ArcPath, bool)> = self .watch_patterns .iter() - .filter(|p| !new_paths.contains(&p.path)) - .map(|p| p.path.clone()) + .filter(|p| !new_patterns.contains(*p)) + .map(|p| { + ( + p.path.clone(), + matches!(p.mode, notify::RecursiveMode::Recursive), + ) + }) .collect(); - for path in &stale_paths { + for (path, _) in &stale_patterns { if let Some(watcher) = &mut self.inner && let Err(e) = watcher.unwatch(path) && !matches!(e.kind, notify::ErrorKind::WatchNotFound) @@ -104,9 +107,13 @@ impl DiskWatcher { } } - self - .watch_patterns - .retain(|p| !stale_paths.contains(&p.path)); + // notify's inotify backend removes every descendant watch when a recursive + // parent is unwatched, so retained children must also be registered again. + self.watch_patterns.retain(|p| { + !stale_patterns.iter().any(|(path, recursive)| { + p.path == *path || (*recursive && p.path.as_ref().starts_with(path.as_ref())) + }) + }); for pattern in new_patterns { if self.watch_patterns.contains(&pattern) { @@ -133,13 +140,19 @@ impl DiskWatcher { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{ + sync::Arc, + time::{Duration, Instant}, + }; use rspack_paths::ArcPath; use tokio::sync::mpsc; use super::*; - use crate::paths::PathManager; + use crate::{ + analyzer::{Analyzer, RecommendedAnalyzer}, + paths::PathManager, + }; fn create_disk_watcher() -> DiskWatcher { let (tx, _rx) = mpsc::unbounded_channel(); @@ -207,4 +220,173 @@ mod tests { assert!(paths.contains(&ArcPath::from(dir_c))); assert!(!paths.contains(&ArcPath::from(dir_a))); } + + #[test] + fn test_watch_replaces_recursive_mode_for_existing_path() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let dir = ArcPath::from(temp_dir.path().canonicalize().unwrap()); + let mut watcher = create_disk_watcher(); + + watcher + .watch(std::iter::once(WatchPattern { + path: dir.clone(), + mode: notify::RecursiveMode::NonRecursive, + })) + .unwrap(); + watcher + .watch(std::iter::once(WatchPattern { + path: dir.clone(), + mode: notify::RecursiveMode::Recursive, + })) + .unwrap(); + + assert_eq!(watcher.watch_patterns.len(), 1); + assert!(watcher.watch_patterns.contains(&WatchPattern { + path: dir.clone(), + mode: notify::RecursiveMode::Recursive, + })); + + watcher + .watch(std::iter::once(WatchPattern { + path: dir.clone(), + mode: notify::RecursiveMode::NonRecursive, + })) + .unwrap(); + + assert_eq!(watcher.watch_patterns.len(), 1); + assert!(watcher.watch_patterns.contains(&WatchPattern { + path: dir, + mode: notify::RecursiveMode::NonRecursive, + })); + } + + #[test] + fn test_removing_recursive_parent_keeps_retained_child_observable() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let parent = temp_dir.path().canonicalize().unwrap(); + let child = parent.join("child"); + let file = child.join("file.txt"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::write(&file, "before").unwrap(); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let path_manager = Arc::new(PathManager::default()); + path_manager + .update( + ( + std::iter::once(ArcPath::from(file.clone())), + std::iter::empty(), + ), + (std::iter::empty(), std::iter::empty()), + (std::iter::empty(), std::iter::empty()), + ) + .unwrap(); + let trigger = Arc::new(trigger::Trigger::new(path_manager, tx)); + let mut watcher = DiskWatcher::new(false, None, trigger); + + watcher + .watch( + [ + WatchPattern { + path: ArcPath::from(parent), + mode: notify::RecursiveMode::Recursive, + }, + WatchPattern { + path: ArcPath::from(child.clone()), + mode: notify::RecursiveMode::NonRecursive, + }, + ] + .into_iter(), + ) + .unwrap(); + watcher + .watch(std::iter::once(WatchPattern { + path: ArcPath::from(child), + mode: notify::RecursiveMode::NonRecursive, + })) + .unwrap(); + + std::thread::sleep(Duration::from_millis(100)); + while rx.try_recv().is_ok() {} + std::fs::write(&file, "after").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(2); + let observed = loop { + if let Ok(events) = rx.try_recv() + && events.iter().any(|event| event.path.as_ref() == file) + { + break true; + } + if Instant::now() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(10)); + }; + + assert!(observed, "retained child was no longer watched"); + } + + #[test] + fn test_recursive_context_observes_file_in_new_grandchild() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let parent = temp_dir.path().canonicalize().unwrap(); + let child = parent.join("child"); + let existing = child.join("existing.txt"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::write(&existing, "existing").unwrap(); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let path_manager = Arc::new(PathManager::default()); + path_manager + .update( + (std::iter::once(ArcPath::from(existing)), std::iter::empty()), + ( + std::iter::once(ArcPath::from(parent.clone())), + std::iter::empty(), + ), + (std::iter::empty(), std::iter::empty()), + ) + .unwrap(); + let trigger = Arc::new(trigger::Trigger::new(path_manager.clone(), tx)); + let mut watcher = DiskWatcher::new(false, None, trigger); + + watcher + .watch(std::iter::once(WatchPattern { + path: ArcPath::from(parent.clone()), + mode: notify::RecursiveMode::Recursive, + })) + .unwrap(); + watcher + .watch( + RecommendedAnalyzer::default() + .analyze(path_manager.access()) + .into_iter(), + ) + .unwrap(); + + let grandchild = child.join("new"); + std::thread::sleep(Duration::from_millis(100)); + std::fs::create_dir(&grandchild).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + while rx.try_recv().is_ok() {} + std::fs::write(grandchild.join("created.txt"), "created").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(2); + let observed = loop { + if let Ok(events) = rx.try_recv() + && events.iter().any(|event| event.path.as_ref() == parent) + { + break true; + } + if Instant::now() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(10)); + }; + + assert!( + observed, + "recursive context did not watch the new grandchild" + ); + } } diff --git a/crates/rspack_watcher/tests/watcher.rs b/crates/rspack_watcher/tests/watcher.rs index 22ce9effd1fb..2233e46664f1 100644 --- a/crates/rspack_watcher/tests/watcher.rs +++ b/crates/rspack_watcher/tests/watcher.rs @@ -132,3 +132,33 @@ fn should_emit_remove_when_a_watched_file_is_deleted() { }, ); } + +#[test] +fn should_watch_a_file_created_in_an_existing_context_child() { + let mut helper = h!(FsWatcherOptions { + aggregate_timeout: Some(100), + ..Default::default() + }); + std::fs::create_dir_all(helper.join("assets/empty-child")).unwrap(); + + let rx = watch!(dirs @ helper, "assets"); + + helper.tick(|| { + helper.file("assets/empty-child/created.txt"); + }); + + let change_events = c!(); + helper.collect_events( + rx, + |event, _| { + event.assert_changed(helper.join("assets")); + add!(change_events); + }, + |changes, abort| { + changes.assert_changed(helper.join("assets")); + *abort = true; + }, + ); + + assert!(load!(change_events) > 0); +} diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js new file mode 100644 index 000000000000..d5d35f6e3b2c --- /dev/null +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -0,0 +1,338 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { CopyRspackPlugin, Stats, rspack } = require("@rspack/core"); + +const fixtureRoot = path.resolve(__dirname, "js/copy-plugin-cache"); + +function write(root, filename, content) { + const file = path.join(root, filename); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function remove(root, filename) { + const file = path.join(root, filename); + fs.rmSync(file); + return file; +} + +function createCompiler(name, patterns, nativeWatcher = false, prepare) { + const root = path.join(fixtureRoot, name); + fs.rmSync(root, { recursive: true, force: true }); + write(root, "src/index.js", "module.exports = 'initial';\n"); + prepare?.(root); + + return { + root, + compiler: rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + experiments: { nativeWatcher }, + entry: "./src/index.js", + output: { path: path.join(root, "dist"), filename: "main.js" }, + plugins: [new CopyRspackPlugin({ patterns })] + }) + }; +} + +function compile(compiler) { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats.hasErrors()) { + return reject(new Error(stats.toString({ all: false, errors: true }))); + } + resolve(compiler._lastCompilation); + }); + }); +} + +function rebuild(compiler, modifiedFiles = [], removedFiles = []) { + return new Promise((resolve, reject) => { + compiler.__internal__rebuild( + new Set(modifiedFiles), + new Set(removedFiles), + error => { + if (error) return reject(error); + const compilation = compiler._lastCompilation; + if (compilation.errors.length > 0) { + return reject( + new Error(new Stats(compilation).toString({ all: false, errors: true })) + ); + } + resolve(compilation); + } + ); + }); +} + +function watch(compiler) { + const builds = []; + const waiters = []; + const watching = compiler.watch({ aggregateTimeout: 0 }, (error, stats) => { + const build = { error, stats }; + const waiter = waiters.shift(); + if (waiter) waiter(build); + else builds.push(build); + }); + + return { + next(timeoutMs = 5000) { + if (builds.length > 0) return Promise.resolve(builds.shift()); + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("timed out waiting for a copy-plugin rebuild")), + timeoutMs + ); + waiters.push(build => { + clearTimeout(timeout); + resolve(build); + }); + }); + }, + close() { + return new Promise((resolve, reject) => { + watching.close(error => (error ? reject(error) : resolve())); + }); + } + }; +} + +function asset(compilation, filename) { + return compilation.getAsset(filename)?.source.source().toString(); +} + +async function nextBuildWithAsset(watching, filename) { + const deadline = Date.now() + 5000; + + while (Date.now() < deadline) { + const build = await watching.next(deadline - Date.now()); + if (build.error || asset(build.stats.compilation, filename) !== undefined) { + return build; + } + } + + throw new Error(`timed out waiting for copied asset '${filename}'`); +} + +function reusedPatterns(compilation) { + const logging = new Stats(compilation).toJson({ + all: false, + logging: false, + loggingDebug: [/CopyRspackPlugin/] + }).logging; + + return Object.values(logging || {}) + .flatMap(group => group.entries || []) + .filter(entry => + entry.message?.startsWith("reusing unchanged copy pattern") + ).length; +} + +async function close(compiler) { + await new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); +} + +describe("CopyRspackPlugin pattern cache", () => { + afterAll(() => { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it.each([ + ["watchpack", false], + ["native watcher", true] + ])( + "watches the stable glob base and copies a newly populated sibling with %s", + async (watcherName, nativeWatcher) => { + const { root, compiler } = createCompiler( + `glob-sibling-${watcherName.replace(" ", "-")}`, + [{ from: "assets/*/*.txt", to: "copied", toType: "dir" }], + nativeWatcher, + root => { + write(root, "assets/a/one.txt", "one\n"); + fs.mkdirSync(path.join(root, "assets/b"), { recursive: true }); + } + ); + const watching = watch(compiler); + + try { + const initial = await watching.next(); + expect(initial.error).toBeNull(); + expect(initial.stats.hasErrors()).toBe(false); + expect([...initial.stats.compilation.contextDependencies]).toContain( + path.join(root, "assets") + ); + expect(asset(initial.stats.compilation, "copied/assets/a/one.txt")).toBe( + "one\n" + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + write(root, "assets/b/two.txt", "two\n"); + const sibling = await nextBuildWithAsset( + watching, + "copied/assets/b/two.txt" + ); + + expect(sibling.error).toBeNull(); + expect(sibling.stats.hasErrors()).toBe(false); + expect(asset(sibling.stats.compilation, "copied/assets/b/two.txt")).toBe( + "two\n" + ); + + write(root, "src/index.js", "module.exports = 'changed';\n"); + const updated = await watching.next(); + + expect(updated.error).toBeNull(); + expect(updated.stats.hasErrors()).toBe(false); + expect(asset(updated.stats.compilation, "copied/assets/a/one.txt")).toBe( + "one\n" + ); + expect(asset(updated.stats.compilation, "copied/assets/b/two.txt")).toBe( + "two\n" + ); + } finally { + await watching.close(); + } + } + ); + + it.each([ + ["directory", "assets/nested", "copied-dir/one.txt"], + ["file", "assets/nested/one.txt", "copied-file/one.txt"] + ])("invalidates a %s pattern for an ancestor-directory event", async (kind, from, emitted) => { + const { root, compiler } = createCompiler(`ancestor-${kind}`, [ + { from, to: kind === "file" ? "copied-file" : "copied-dir", toType: "dir" } + ]); + write(root, "assets/nested/one.txt", "before\n"); + + try { + const initial = await compile(compiler); + expect(asset(initial, emitted)).toBe("before\n"); + + write(root, "assets/nested/one.txt", "after\n"); + const updated = await rebuild(compiler, [path.join(root, "assets")]); + + expect(asset(updated, emitted)).toBe("after\n"); + expect(reusedPatterns(updated)).toBe(0); + } finally { + await close(compiler); + } + }); + + it("invalidates modified and removed children, including removing the last glob match and re-adding it", async () => { + const { root, compiler } = createCompiler("glob-add-remove", [ + { + from: "assets/glob/**/*.txt", + to: "copied", + toType: "dir", + noErrorOnMissing: true, + globOptions: { ignore: ["**/skip-*.txt"] } + } + ]); + const one = write(root, "assets/glob/nested/one.txt", "one\n"); + + try { + const initial = await compile(compiler); + expect(asset(initial, "copied/assets/glob/nested/one.txt")).toBe("one\n"); + + const two = write(root, "assets/glob/nested/two.txt", "two\n"); + let updated = await rebuild(compiler, [two]); + expect(asset(updated, "copied/assets/glob/nested/two.txt")).toBe("two\n"); + + write(root, "assets/glob/nested/two.txt", "two-edited\n"); + updated = await rebuild(compiler, [two]); + expect(asset(updated, "copied/assets/glob/nested/two.txt")).toBe("two-edited\n"); + + remove(root, "assets/glob/nested/one.txt"); + remove(root, "assets/glob/nested/two.txt"); + updated = await rebuild(compiler, [], [one, two]); + expect(asset(updated, "copied/assets/glob/nested/one.txt")).toBeUndefined(); + expect(asset(updated, "copied/assets/glob/nested/two.txt")).toBeUndefined(); + + const three = write(root, "assets/glob/other/three.txt", "three\n"); + const ignored = write(root, "assets/glob/other/skip-three.txt", "ignored\n"); + updated = await rebuild(compiler, [three, ignored]); + expect(asset(updated, "copied/assets/glob/other/three.txt")).toBe("three\n"); + expect(asset(updated, "copied/assets/glob/other/skip-three.txt")).toBeUndefined(); + } finally { + await close(compiler); + } + }); + + it("reuses unchanged static patterns but never caches callback, template, or permission patterns", async () => { + let transformCalls = 0; + let destinationCalls = 0; + const { root, compiler } = createCompiler("cache-policy", [ + { from: "assets/static", to: "static" }, + { + from: "assets/transform", + to: "transform", + transform(content) { + transformCalls += 1; + return content; + } + }, + { + from: "assets/function", + to({ absoluteFilename }) { + destinationCalls += 1; + return `function/${path.basename(absoluteFilename)}`; + } + }, + { from: "assets/template", to: "template/[name][ext]" }, + { from: "assets/permissions", to: "permissions", copyPermissions: true } + ]); + write(root, "assets/static/one.txt", "static\n"); + write(root, "assets/transform/one.txt", "transform\n"); + write(root, "assets/function/one.txt", "function\n"); + write(root, "assets/template/one.txt", "template\n"); + write(root, "assets/permissions/one.txt", "permissions\n"); + + try { + await compile(compiler); + expect(transformCalls).toBe(1); + expect(destinationCalls).toBe(1); + + const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); + const updated = await rebuild(compiler, [entry]); + + expect(reusedPatterns(updated)).toBe(1); + expect(transformCalls).toBe(2); + expect(destinationCalls).toBe(2); + expect(asset(updated, "static/one.txt")).toBe("static\n"); + expect(asset(updated, "transform/one.txt")).toBe("transform\n"); + expect(asset(updated, "function/one.txt")).toBe("function\n"); + expect(asset(updated, "template/one.txt")).toBe("template\n"); + expect(asset(updated, "permissions/one.txt")).toBe("permissions\n"); + } finally { + await close(compiler); + } + }); + + it("does not reuse cached results across compiler.run calls without watcher provenance", async () => { + const { root, compiler } = createCompiler("run-twice", [ + { from: "assets/source", to: "copied" } + ]); + write(root, "assets/source/one.txt", "before\n"); + + try { + const initial = await compile(compiler); + expect(asset(initial, "copied/one.txt")).toBe("before\n"); + + write(root, "assets/source/one.txt", "after\n"); + const updated = await compile(compiler); + + expect(asset(updated, "copied/one.txt")).toBe("after\n"); + expect(reusedPatterns(updated)).toBe(0); + } finally { + await close(compiler); + } + }); +}); diff --git a/tests/rspack-test/configCases/plugins/copy-plugin-context-dependencies/rspack.config.js b/tests/rspack-test/configCases/plugins/copy-plugin-context-dependencies/rspack.config.js index 4e0622174694..e0c841cbd326 100644 --- a/tests/rspack-test/configCases/plugins/copy-plugin-context-dependencies/rspack.config.js +++ b/tests/rspack-test/configCases/plugins/copy-plugin-context-dependencies/rspack.config.js @@ -8,10 +8,9 @@ module.exports = { new CopyRspackPlugin({ patterns: [ { - // A glob `from` registers the closest common parent dir of the matched - // files as a context dependency. On Windows the glob matcher yields - // forward-slash paths, so the registered dir must be normalized back to - // native separators to stay consistent with the rest of the dep graph. + // A glob `from` registers its stable, non-magic base directory as a + // context dependency so new sibling matches are observed. The base must + // be normalized to stay consistent with the rest of the dependency graph. from: './public/**/*', }, ], @@ -19,6 +18,9 @@ module.exports = { { apply(compiler) { compiler.hooks.done.tap('DonePlugin', (stats) => { + expect([...stats.compilation.contextDependencies]).toContain( + path.resolve(__dirname, 'public'), + ); for (const dir of stats.compilation.contextDependencies) { expect(dir).toBe(path.normalize(dir)); } From 601ac4567a4da4a9ddb8844845510f9b2368ad07 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:38 +0000 Subject: [PATCH 09/29] fix(cache): keep compilation dependency deltas scoped --- .../rspack/tests/compilation_dependencies.rs | 31 +++++++++++++++++++ crates/rspack_core/src/compilation/mod.rs | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 crates/rspack/tests/compilation_dependencies.rs diff --git a/crates/rspack/tests/compilation_dependencies.rs b/crates/rspack/tests/compilation_dependencies.rs new file mode 100644 index 000000000000..962ab6ee95e8 --- /dev/null +++ b/crates/rspack/tests/compilation_dependencies.rs @@ -0,0 +1,31 @@ +use rspack::builder::Builder as _; +use rspack_core::Compiler; +use rspack_paths::ArcPath; +use rspack_tasks::within_compiler_context_for_testing_sync; + +#[test] +fn compilation_level_dependencies_stay_in_their_own_added_iterators() { + within_compiler_context_for_testing_sync(|| { + let mut compiler = Compiler::builder().build().expect("compiler should build"); + let compilation = &mut compiler.compilation; + let file = ArcPath::from("/project/file.tsx"); + let context = ArcPath::from("/project/context"); + let missing = ArcPath::from("/project/missing.tsx"); + let build = ArcPath::from("/project/build.config.js"); + + compilation.file_dependencies.insert(file.clone()); + compilation.context_dependencies.insert(context.clone()); + compilation.missing_dependencies.insert(missing.clone()); + compilation.build_dependencies.insert(build.clone()); + + let (_, file_added, _, _) = compilation.file_dependencies(); + let (_, context_added, _, _) = compilation.context_dependencies(); + let (_, missing_added, _, _) = compilation.missing_dependencies(); + let (_, build_added, _, _) = compilation.build_dependencies(); + + assert_eq!(file_added.cloned().collect::>(), [file]); + assert_eq!(context_added.cloned().collect::>(), [context]); + assert_eq!(missing_added.cloned().collect::>(), [missing]); + assert_eq!(build_added.cloned().collect::>(), [build]); + }); +} diff --git a/crates/rspack_core/src/compilation/mod.rs b/crates/rspack_core/src/compilation/mod.rs index 466b34317ff1..c558ac205e3b 100644 --- a/crates/rspack_core/src/compilation/mod.rs +++ b/crates/rspack_core/src/compilation/mod.rs @@ -525,7 +525,7 @@ impl Compilation { .build_module_graph_artifact .context_dependencies .added_files() - .chain(&self.file_dependencies); + .chain(&self.context_dependencies); let updated_files = self .build_module_graph_artifact .context_dependencies @@ -554,7 +554,7 @@ impl Compilation { .build_module_graph_artifact .missing_dependencies .added_files() - .chain(&self.file_dependencies); + .chain(&self.missing_dependencies); let updated_files = self .build_module_graph_artifact .missing_dependencies @@ -583,7 +583,7 @@ impl Compilation { .build_module_graph_artifact .build_dependencies .added_files() - .chain(&self.file_dependencies); + .chain(&self.build_dependencies); let updated_files = self .build_module_graph_artifact .build_dependencies From 182ab3ca38689308fd2dc2e286bbe975d30ee0dd Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:42 +0000 Subject: [PATCH 10/29] test(binding): guard module graph connection cleanup --- .../gc-check-module-graph-connection.cjs | 74 +++++++++++++++++++ .../compilerCases/tsfn-lifecycle.js | 14 ++++ 2 files changed, 88 insertions(+) create mode 100644 tests/rspack-test/compilerCases/fixtures/tsfn-lifecycle/gc-check-module-graph-connection.cjs diff --git a/tests/rspack-test/compilerCases/fixtures/tsfn-lifecycle/gc-check-module-graph-connection.cjs b/tests/rspack-test/compilerCases/fixtures/tsfn-lifecycle/gc-check-module-graph-connection.cjs new file mode 100644 index 000000000000..7d86286c0eda --- /dev/null +++ b/tests/rspack-test/compilerCases/fixtures/tsfn-lifecycle/gc-check-module-graph-connection.cjs @@ -0,0 +1,74 @@ +const rspack = require("@rspack/core"); +const { createFsFromVolume, Volume } = require("memfs"); +const { + closeCompiler, + createGCTracker, + runCompiler, +} = require("./helpers.cjs"); + +async function main() { + const gcTracker = createGCTracker(); + let build = 0; + + const compiler = rspack({ + context: __dirname, + mode: "development", + entry: "./entry.js", + output: { + path: "/", + filename: "bundle.js", + }, + plugins: [ + { + apply(compiler) { + compiler.hooks.compilation.tap( + "TsfnLifecycleModuleGraphConnection", + compilation => { + compilation.hooks.afterProcessAssets.tap( + "TsfnLifecycleModuleGraphConnection", + () => { + const entry = compilation.entries.values().next().value; + const connection = compilation.moduleGraph.getConnection( + entry.dependencies[0], + ); + + if (!connection?.module) { + throw new Error( + "expected an entry module graph connection", + ); + } + + build += 1; + gcTracker.track( + connection, + `module graph connection ${build}`, + ); + }, + ); + }, + ); + }, + }, + ], + }); + compiler.outputFileSystem = createFsFromVolume(new Volume()); + + try { + let firstStats = await runCompiler(compiler); + firstStats = null; + await runCompiler(compiler); + + if (build !== 2) { + throw new Error(`expected two builds, received ${build}`); + } + + await gcTracker.waitForCollection("module graph connection 1"); + } finally { + await closeCompiler(compiler); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/rspack-test/compilerCases/tsfn-lifecycle.js b/tests/rspack-test/compilerCases/tsfn-lifecycle.js index 9e1499efa640..60f572e35fc1 100644 --- a/tests/rspack-test/compilerCases/tsfn-lifecycle.js +++ b/tests/rspack-test/compilerCases/tsfn-lifecycle.js @@ -93,6 +93,20 @@ module.exports = [ ); }, }, + { + description: + "should garbage collect module graph connections from a previous build", + async build() { + await runChild( + path.join( + __dirname, + "fixtures", + "tsfn-lifecycle", + "gc-check-module-graph-connection.cjs", + ), + ); + }, + }, { description: "should report a clear error when APIs are called after close", async build() { From 32d98042fa925cb837924986a90b6cb249a64095 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:50:48 +0000 Subject: [PATCH 11/29] fix(hmr): consolidate lazy compilation and runtime correctness Port the final, CI-validated source and regression diff from web-infra-dev/rspack#14753 onto the consolidated HMR stack. This retains typed watch provenance, native event draining, initial-chunk/full-hash handling, and disposed-factory correctness. Co-authored-by: ScriptedAlchemy <25274700+ScriptedAlchemy@users.noreply.github.com> --- crates/node_binding/napi-binding.d.ts | 3 + .../rspack_binding_api/src/native_watcher.rs | 48 +- .../rspack_plugin_extract_css/src/plugin.rs | 9 +- .../src/runtime/hot_module_replacement.ejs | 49 +- crates/rspack_plugin_runtime/src/lib.rs | 4 +- .../src/runtime_module/get_chunk_filename.rs | 124 +-- .../src/runtime_module/mod.rs | 2 +- .../javascript_hot_module_replacement.ejs | 42 +- .../src/runtime_plugin.rs | 20 +- crates/rspack_watcher/src/executor.rs | 208 ++++- crates/rspack_watcher/src/lib.rs | 25 +- crates/rspack_watcher/tests/pending_events.rs | 207 +++++ packages/rspack/module.d.ts | 2 + packages/rspack/src/Compilation.ts | 8 + packages/rspack/src/Compiler.ts | 5 +- packages/rspack/src/MultiCompiler.ts | 23 +- packages/rspack/src/MultiWatching.ts | 13 +- packages/rspack/src/NativeWatchFileSystem.ts | 44 +- packages/rspack/src/Watching.ts | 63 +- .../lazy-compilation/middleware.ts | 2 +- packages/rspack/src/exports.ts | 1 + .../NativeWatcherGeneration.test.js | 92 +++ tests/rspack-test/compilerCases/watching.js | 116 +++ .../__snapshots__/web/0.snap.txt | 13 + .../__snapshots__/web/1.snap.txt | 49 ++ .../chunks/entry-filename-full-hash/async.js | 1 + .../chunks/entry-filename-full-hash/index.js | 13 + .../entry-filename-full-hash/rspack.config.js | 17 + .../entry-filename-full-hash/test.filter.js | 2 + .../chunks/entry-filename-full-hash/value.js | 3 + .../__snapshots__/web/0.snap.txt | 13 + .../__snapshots__/web/1.snap.txt | 780 ++++++++++++++++++ .../chunks/initial-chunks-hmr/index.js | 13 + .../chunks/initial-chunks-hmr/initial.js | 5 + .../node_modules/lib-js/a.js | 1 + .../node_modules/lib-js/b.js | 1 + .../initial-chunks-hmr/rspack.config.js | 27 + .../chunks/initial-chunks-hmr/test.filter.js | 2 + .../__snapshots__/web/1.snap.txt | 48 +- .../__snapshots__/web/0.snap.txt | 12 + .../css/initial-css-fullhash/index.css | 3 + .../css/initial-css-fullhash/index.js | 9 + .../css/initial-css-fullhash/rspack.config.js | 15 + .../css/initial-css-fullhash/test.config.js | 3 + .../__snapshots__/web/1.snap.txt | 44 +- .../__snapshots__/web/1.snap.txt | 44 +- .../__snapshots__/web/1.snap.txt | 44 +- .../__snapshots__/web/0.snap.txt | 12 + .../__snapshots__/web/1.snap.txt | 59 ++ .../a.js | 1 + .../b.js | 1 + .../index.js | 21 + .../module.js | 3 + .../__snapshots__/web/0.snap.txt | 12 + .../__snapshots__/web/1.snap.txt | 48 ++ .../a.js | 4 + .../index.js | 19 + .../module.js | 3 + .../__snapshots__/web/0.snap.txt | 12 + .../__snapshots__/web/1.snap.txt | 59 ++ .../__snapshots__/web/2.snap.txt | 97 +++ .../preserve-disposed-module-factories/a.js | 4 + .../preserve-disposed-module-factories/b.js | 1 + .../child.js | 1 + .../index.js | 49 ++ .../module.js | 5 + .../share-plugin/__snapshots__/web/1.snap.txt | 44 +- .../multiCompilerCases/invalidate.js | 71 +- website/docs/en/api/runtime-api/hmr.mdx | 1 + website/docs/zh/api/runtime-api/hmr.mdx | 1 + 70 files changed, 2616 insertions(+), 184 deletions(-) create mode 100644 crates/rspack_watcher/tests/pending_events.rs create mode 100644 tests/rspack-test/NativeWatcherGeneration.test.js create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/1.snap.txt create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/async.js create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/index.js create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/rspack.config.js create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/test.filter.js create mode 100644 tests/rspack-test/hotCases/chunks/entry-filename-full-hash/value.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/index.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/initial.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/a.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/b.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/rspack.config.js create mode 100644 tests/rspack-test/hotCases/chunks/initial-chunks-hmr/test.filter.js create mode 100644 tests/rspack-test/hotCases/css/initial-css-fullhash/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/css/initial-css-fullhash/index.css create mode 100644 tests/rspack-test/hotCases/css/initial-css-fullhash/index.js create mode 100644 tests/rspack-test/hotCases/css/initial-css-fullhash/rspack.config.js create mode 100644 tests/rspack-test/hotCases/css/initial-css-fullhash/test.config.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/1.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/a.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/b.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/index.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/module.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/1.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/a.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/index.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/module.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/0.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/1.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/2.snap.txt create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/b.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/child.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js create mode 100644 tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/module.js diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index aa910080cdc2..5bcca3317890 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -466,6 +466,8 @@ export declare class NativeWatcher { constructor(options: NativeWatcherOptions) watch(files: [Array, Array], directories: [Array, Array], missing: [Array, Array], startTime: bigint, callback: (err: Error | null, result: NativeWatchResult) => void, callbackUndelayed: (event: NativeWatchUndelayedEvent) => void): void triggerEvent(kind: 'change' | 'remove' | 'create', path: string): void + takePendingEvents(): NativeWatchResult + acknowledgePendingEvents(generation: number): void /** * # Safety * @@ -480,6 +482,7 @@ export declare class NativeWatcher { export declare class NativeWatchResult { changedFiles: Array removedFiles: Array + generation: number } diff --git a/crates/rspack_binding_api/src/native_watcher.rs b/crates/rspack_binding_api/src/native_watcher.rs index ae9cc99fe6ab..6a829a5accb0 100644 --- a/crates/rspack_binding_api/src/native_watcher.rs +++ b/crates/rspack_binding_api/src/native_watcher.rs @@ -44,6 +44,7 @@ pub struct NativeWatcherOptions { pub struct NativeWatchResult { pub changed_files: Vec, pub removed_files: Vec, + pub generation: u32, } /// A single, undelayed file system event delivered to the `callbackUndelayed` @@ -144,6 +145,21 @@ impl NativeWatcher { } } + #[napi] + pub fn take_pending_events(&self) -> NativeWatchResult { + let (changed_files, removed_files, generation) = self.watcher.take_pending_events(); + NativeWatchResult { + changed_files: changed_files.into_iter().collect(), + removed_files: removed_files.into_iter().collect(), + generation, + } + } + + #[napi] + pub fn acknowledge_pending_events(&self, generation: u32) { + self.watcher.acknowledge_pending_events(generation); + } + #[napi(ts_return_type = "Promise")] /// # Safety /// @@ -231,25 +247,43 @@ impl JsEventHandler { Ok(Self { inner: callback }) } -} -impl rspack_watcher::EventAggregateHandler for JsEventHandler { - fn on_event_handle( + fn deliver( &self, changed_files: rspack_util::fx_hash::FxHashSet, deleted_files: rspack_util::fx_hash::FxHashSet, + generation: u32, ) { - let changed_files_vec: Vec = changed_files.into_iter().collect(); - let deleted_files_vec: Vec = deleted_files.into_iter().collect(); let result = NativeWatchResult { - changed_files: changed_files_vec, - removed_files: deleted_files_vec, + changed_files: changed_files.into_iter().collect(), + removed_files: deleted_files.into_iter().collect(), + generation, }; self.inner.call( Ok(result), napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking, ); } +} + +impl rspack_watcher::EventAggregateHandler for JsEventHandler { + fn on_event_handle( + &self, + changed_files: rspack_util::fx_hash::FxHashSet, + deleted_files: rspack_util::fx_hash::FxHashSet, + ) { + self.deliver(changed_files, deleted_files, 0); + } + + fn on_event_handle_with_generation( + &self, + changed_files: rspack_util::fx_hash::FxHashSet, + deleted_files: rspack_util::fx_hash::FxHashSet, + generation: u32, + ) -> bool { + self.deliver(changed_files, deleted_files, generation); + true + } fn on_error(&self, error: rspack_error::Error) { // Handle error, maybe log it or notify the user diff --git a/crates/rspack_plugin_extract_css/src/plugin.rs b/crates/rspack_plugin_extract_css/src/plugin.rs index d90431ae8ea3..41e1bb95016e 100644 --- a/crates/rspack_plugin_extract_css/src/plugin.rs +++ b/crates/rspack_plugin_extract_css/src/plugin.rs @@ -25,7 +25,7 @@ use rspack_hook::{plugin, plugin_hook}; use rspack_plugin_javascript::{ BoxJavascriptParserPlugin, parser_and_generator::JavaScriptParserAndGenerator, }; -use rspack_plugin_runtime::GetChunkFilenameRuntimeModule; +use rspack_plugin_runtime::{ChunkFilenameKind, GetChunkFilenameRuntimeModule}; use rustc_hash::{FxHashMap, FxHashSet}; use ustr::Ustr; @@ -555,8 +555,10 @@ async fn runtime_requirement_in_tree( *chunk_ukey, Box::new(GetChunkFilenameRuntimeModule::new( &compilation.runtime_template, - "css", - "mini-css", + ChunkFilenameKind { + content_type: "css", + runtime_module_name: "mini-css", + }, SOURCE_TYPE[0], global, move |runtime_requirements| { @@ -574,6 +576,7 @@ async fn runtime_requirement_in_tree( } }) }, + *chunk_ukey, )), )); diff --git a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs index a59d89090eb6..a0b6fb2b3eb7 100644 --- a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs +++ b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs @@ -304,11 +304,50 @@ function hotApply(options) { return internalApply(options); } +function finalizeModuleFactoryTransaction(moduleFactoryTransaction) { + if (moduleFactoryTransaction.finalized) return; + moduleFactoryTransaction.finalized = true; + var finalizers = moduleFactoryTransaction.finalizers; + moduleFactoryTransaction.finalizers = []; + finalizers.forEach(function (finalize) { + finalize(); + }); +} + function internalApply(options) { options = options || {}; + if (!options.preserveDisposedModuleFactories) { + return internalApplyUpdate(options); + } + + var moduleFactoryTransaction = { + finalizers: [], + finalized: false + }; + var applyResult; + try { + applyResult = internalApplyUpdate(options, moduleFactoryTransaction); + } catch (error) { + finalizeModuleFactoryTransaction(moduleFactoryTransaction); + throw error; + } + + return applyResult.then( + function (result) { + finalizeModuleFactoryTransaction(moduleFactoryTransaction); + return result; + }, + function (error) { + finalizeModuleFactoryTransaction(moduleFactoryTransaction); + throw error; + } + ); +} + +function internalApplyUpdate(options, moduleFactoryTransaction) { applyInvalidatedModules(); var results = currentUpdateApplyHandlers.map(function (handler) { - return handler(options); + return handler(options, moduleFactoryTransaction); }); currentUpdateApplyHandlers = undefined; var errors = results @@ -350,13 +389,16 @@ function internalApply(options) { return Promise.all([disposePromise, applyPromise]).then(function () { if (error) { + if (moduleFactoryTransaction) { + finalizeModuleFactoryTransaction(moduleFactoryTransaction); + } return setStatus("fail").then(function () { throw error; }); } if (queuedInvalidatedModules) { - return internalApply(options).then(function (list) { + return internalApplyUpdate(options, moduleFactoryTransaction).then(function (list) { outdatedModules.forEach(function (moduleId) { if (list.indexOf(moduleId) < 0) list.push(moduleId); }); @@ -364,6 +406,9 @@ function internalApply(options) { }); } + if (moduleFactoryTransaction) { + finalizeModuleFactoryTransaction(moduleFactoryTransaction); + } return setStatus("idle").then(function () { return outdatedModules; }); diff --git a/crates/rspack_plugin_runtime/src/lib.rs b/crates/rspack_plugin_runtime/src/lib.rs index ab4a650fea41..ce8a8e34ceba 100644 --- a/crates/rspack_plugin_runtime/src/lib.rs +++ b/crates/rspack_plugin_runtime/src/lib.rs @@ -19,8 +19,8 @@ mod import_scripts_chunk_loading; pub use import_scripts_chunk_loading::ImportScriptsChunkLoadingPlugin; mod runtime_module; pub use runtime_module::{ - EXPORT_REQUIRE_RUNTIME_MODULE_ID, GetChunkFilenameRuntimeModule, chunk_has_css, - is_enabled_for_chunk, stringify_chunks, + ChunkFilenameKind, EXPORT_REQUIRE_RUNTIME_MODULE_ID, GetChunkFilenameRuntimeModule, + chunk_has_css, is_enabled_for_chunk, stringify_chunks, }; mod startup_chunk_dependencies; pub use startup_chunk_dependencies::StartupChunkDependenciesPlugin; diff --git a/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs b/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs index 224f46814a61..4e92e85769e0 100644 --- a/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs +++ b/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs @@ -5,7 +5,7 @@ use rspack_cacheable::with::Unsupported; use rspack_core::{ Chunk, ChunkGraph, ChunkUkey, Compilation, Filename, PathData, RuntimeGlobals, RuntimeModule, RuntimeModuleGenerateContext, RuntimeTemplate, SourceType, get_filename_without_hash_length, - has_hash_placeholder, impl_runtime_module, + impl_runtime_module, }; use rspack_util::{ fx_hash::{FxIndexMap, FxIndexSet}, @@ -19,6 +19,11 @@ use crate::{get_chunk_runtime_requirements, runtime_module::unquoted_stringify}; type GetChunkFilenameAllChunks = Box bool + Sync + Send>; type GetFilenameForChunk = Box Option + Sync + Send>; +pub struct ChunkFilenameKind { + pub content_type: &'static str, + pub runtime_module_name: &'static str, +} + #[impl_runtime_module] pub struct GetChunkFilenameRuntimeModule { #[cacheable(with=Unsupported)] @@ -29,6 +34,7 @@ pub struct GetChunkFilenameRuntimeModule { all_chunks: GetChunkFilenameAllChunks, #[cacheable(with=Unsupported)] filename_for_chunk: GetFilenameForChunk, + chunk_ukey: ChunkUkey, } impl fmt::Debug for GetChunkFilenameRuntimeModule { @@ -40,6 +46,7 @@ impl fmt::Debug for GetChunkFilenameRuntimeModule { .field("source_type", &self.source_type) .field("global", &self.global) .field("all_chunks", &"...") + .field("chunk_ukey", &self.chunk_ukey) .finish() } } @@ -52,23 +59,67 @@ impl GetChunkFilenameRuntimeModule { T: Fn(&Chunk, &Compilation) -> Option + Sync + Send + 'static, >( runtime_template: &RuntimeTemplate, - content_type: &'static str, - name: &'static str, + kind: ChunkFilenameKind, source_type: SourceType, global: String, all_chunks: F, filename_for_chunk: T, + chunk_ukey: ChunkUkey, ) -> Self { Self::with_name( runtime_template, - &format!("get {name} chunk filename"), - content_type, + &format!("get {} chunk filename", kind.runtime_module_name), + kind.content_type, source_type, global, Box::new(all_chunks), Box::new(filename_for_chunk), + chunk_ukey, ) } + + fn get_filename_chunks(&self, compilation: &Compilation) -> Option> { + compilation + .build_chunk_graph_artifact + .chunk_by_ukey + .get(&self.chunk_ukey) + .map(|chunk| { + let runtime_requirements = get_chunk_runtime_requirements(compilation, &chunk.ukey()); + let mut chunks = if (self.all_chunks)(runtime_requirements) { + chunk + .get_all_referenced_chunks(&compilation.build_chunk_graph_artifact.chunk_group_by_ukey) + } else { + let mut chunks = + chunk.get_all_async_chunks(&compilation.build_chunk_graph_artifact.chunk_group_by_ukey); + + if ChunkGraph::get_tree_runtime_requirements(compilation, &chunk.ukey()) + .contains(RuntimeGlobals::ENSURE_CHUNK_INCLUDE_ENTRIES) + { + chunks.extend( + compilation + .build_chunk_graph_artifact + .chunk_graph + .get_runtime_chunk_dependent_chunks_iterable( + &chunk.ukey(), + &compilation.build_chunk_graph_artifact.chunk_by_ukey, + &compilation.build_chunk_graph_artifact.chunk_group_by_ukey, + ), + ); + } + chunks + }; + for entrypoint in chunk.get_all_referenced_async_entrypoints( + &compilation.build_chunk_graph_artifact.chunk_group_by_ukey, + ) { + let entrypoint = compilation + .build_chunk_graph_artifact + .chunk_group_by_ukey + .expect_get(&entrypoint); + chunks.insert(entrypoint.get_entrypoint_chunk()); + } + chunks + }) + } } #[async_trait::async_trait] @@ -77,13 +128,22 @@ impl RuntimeModule for GetChunkFilenameRuntimeModule { &self, compilation: &Compilation, ) -> rspack_core::RuntimeModuleRuntimeRequirements { + let needs_full_hash = self + .get_filename_chunks(compilation) + .into_iter() + .flatten() + .filter_map(|chunk_ukey| { + compilation + .build_chunk_graph_artifact + .chunk_by_ukey + .get(&chunk_ukey) + }) + .filter_map(|chunk| (self.filename_for_chunk)(chunk, compilation)) + .any(|filename| filename.has_hash_placeholder()); + rspack_core::RuntimeModuleRuntimeRequirements { dependencies: { - if (self.source_type == SourceType::JavaScript - && has_hash_placeholder(compilation.options.output.chunk_filename.as_str())) - || (self.source_type == SourceType::Css - && has_hash_placeholder(compilation.options.output.css_chunk_filename.as_str())) - { + if needs_full_hash { RuntimeGlobals::GET_FULL_HASH } else { RuntimeGlobals::default() @@ -117,49 +177,7 @@ impl RuntimeModule for GetChunkFilenameRuntimeModule { ) -> rspack_error::Result { let compilation = context.compilation; let runtime_template = context.runtime_template; - let chunks = self - .chunk() - .and_then(|chunk_ukey| { - compilation - .build_chunk_graph_artifact - .chunk_by_ukey - .get(&chunk_ukey) - }) - .map(|chunk| { - let runtime_requirements = get_chunk_runtime_requirements(compilation, &chunk.ukey()); - if (self.all_chunks)(runtime_requirements) { - chunk - .get_all_referenced_chunks(&compilation.build_chunk_graph_artifact.chunk_group_by_ukey) - } else { - let mut chunks = - chunk.get_all_async_chunks(&compilation.build_chunk_graph_artifact.chunk_group_by_ukey); - - if ChunkGraph::get_tree_runtime_requirements(compilation, &chunk.ukey()) - .contains(RuntimeGlobals::ENSURE_CHUNK_INCLUDE_ENTRIES) - { - chunks.extend( - compilation - .build_chunk_graph_artifact - .chunk_graph - .get_runtime_chunk_dependent_chunks_iterable( - &chunk.ukey(), - &compilation.build_chunk_graph_artifact.chunk_by_ukey, - &compilation.build_chunk_graph_artifact.chunk_group_by_ukey, - ), - ); - } - for entrypoint in chunk.get_all_referenced_async_entrypoints( - &compilation.build_chunk_graph_artifact.chunk_group_by_ukey, - ) { - let entrypoint = compilation - .build_chunk_graph_artifact - .chunk_group_by_ukey - .expect_get(&entrypoint); - chunks.insert(entrypoint.get_entrypoint_chunk()); - } - chunks - } - }); + let chunks = self.get_filename_chunks(compilation); let mut dynamic_filename: Option = None; let mut max_chunk_set_size = 0; diff --git a/crates/rspack_plugin_runtime/src/runtime_module/mod.rs b/crates/rspack_plugin_runtime/src/runtime_module/mod.rs index 7f7693bfa35c..600e4896c0ac 100644 --- a/crates/rspack_plugin_runtime/src/runtime_module/mod.rs +++ b/crates/rspack_plugin_runtime/src/runtime_module/mod.rs @@ -64,7 +64,7 @@ pub use define_property_getters::DefinePropertyGettersRuntimeModule; pub use ensure_chunk::EnsureChunkRuntimeModule; pub use esm_module_decorator::ESMModuleDecoratorRuntimeModule; pub use export_require::{EXPORT_REQUIRE_RUNTIME_MODULE_ID, ExportRequireRuntimeModule}; -pub use get_chunk_filename::GetChunkFilenameRuntimeModule; +pub use get_chunk_filename::{ChunkFilenameKind, GetChunkFilenameRuntimeModule}; pub use get_chunk_update_filename::GetChunkUpdateFilenameRuntimeModule; pub use get_full_hash::GetFullHashRuntimeModule; pub use get_main_filename::GetMainFilenameRuntimeModule; diff --git a/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs b/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs index c432e6390054..573364008547 100644 --- a/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs +++ b/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs @@ -2,7 +2,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (<%- weak(ENSURE_CHUNK_HANDLERS) %>) delete <%- weak(ENSURE_CHUNK_HANDLERS) %>.<%- _loading_method %>Hmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -269,7 +269,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (<%- HAS_OWN_PROPERTY %>(appliedUpdate, updateModuleId)) { - <%- MODULE_FACTORIES %>[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + <%- HAS_OWN_PROPERTY %>(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (<%- HAS_OWN_PROPERTY %>(preservedModuleFactories, moduleId)) { + var module = <%- MODULE_CACHE %>[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = <%- MODULE_CACHE %>[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete <%- MODULE_CACHE %>[moduleId]; + <%- MODULE_FACTORIES %>[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + <%- MODULE_FACTORIES %>[updateModuleId] = newModuleFactory; + } <% if (_is_hot_test) { %> if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/crates/rspack_plugin_runtime/src/runtime_plugin.rs b/crates/rspack_plugin_runtime/src/runtime_plugin.rs index 1a630f4af3d2..e5dc00ac39e4 100644 --- a/crates/rspack_plugin_runtime/src/runtime_plugin.rs +++ b/crates/rspack_plugin_runtime/src/runtime_plugin.rs @@ -22,7 +22,7 @@ use crate::{ RuntimePluginHooks, runtime_module::{ AmdDefineRuntimeModule, AmdOptionsRuntimeModule, AsyncRuntimeModule, - AutoPublicPathRuntimeModule, BaseUriRuntimeModule, ChunkNameRuntimeModule, + AutoPublicPathRuntimeModule, BaseUriRuntimeModule, ChunkFilenameKind, ChunkNameRuntimeModule, ChunkPrefetchPreloadFunctionRuntimeModule, CompatGetDefaultExportRuntimeModule, CreateFakeNamespaceObjectRuntimeModule, CreateScriptRuntimeModule, CreateScriptUrlRuntimeModule, DefinePropertyGettersRuntimeModule, @@ -257,11 +257,15 @@ async fn runtime_requirements_in_tree( *chunk_ukey, GetChunkFilenameRuntimeModule::new( &compilation.runtime_template, - "javascript", - "javascript", + ChunkFilenameKind { + content_type: "javascript", + runtime_module_name: "javascript", + }, SourceType::JavaScript, runtime_template.render_runtime_globals(&RuntimeGlobals::GET_CHUNK_SCRIPT_FILENAME), - |_| false, + |runtime_requirements| { + runtime_requirements.contains(RuntimeGlobals::HMR_DOWNLOAD_UPDATE_HANDLERS) + }, |chunk, compilation| { chunk_has_js(&chunk.ukey(), compilation).then(|| { get_js_chunk_filename_template( @@ -271,6 +275,7 @@ async fn runtime_requirements_in_tree( ) }) }, + *chunk_ukey, ) .boxed(), )); @@ -283,8 +288,10 @@ async fn runtime_requirements_in_tree( *chunk_ukey, GetChunkFilenameRuntimeModule::new( &compilation.runtime_template, - "css", - "css", + ChunkFilenameKind { + content_type: "css", + runtime_module_name: "css", + }, SourceType::Css, runtime_template.render_runtime_globals(&RuntimeGlobals::GET_CHUNK_CSS_FILENAME), |runtime_requirements| { @@ -300,6 +307,7 @@ async fn runtime_requirements_in_tree( .clone() }) }, + *chunk_ukey, ) .boxed(), )); diff --git a/crates/rspack_watcher/src/executor.rs b/crates/rspack_watcher/src/executor.rs index 0dd678373292..670377e6ae74 100644 --- a/crates/rspack_watcher/src/executor.rs +++ b/crates/rspack_watcher/src/executor.rs @@ -1,7 +1,4 @@ -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use std::sync::{Arc, Mutex as SyncMutex, MutexGuard as SyncMutexGuard}; use rspack_util::fx_hash::FxHashSet as HashSet; use tokio::sync::{ @@ -14,17 +11,114 @@ use crate::EventBatch; type ThreadSafetyReceiver = ThreadSafety>; type ThreadSafety = Arc>; +type PendingFiles = Arc>; + +#[derive(Clone, Debug)] +struct AggregatedFiles { + changed: HashSet, + deleted: HashSet, + generation: u32, +} + +impl AggregatedFiles { + fn merge(&mut self, changed: HashSet, deleted: HashSet) { + for path in changed { + self.deleted.remove(&path); + self.changed.insert(path); + } + for path in deleted { + self.changed.remove(&path); + self.deleted.insert(path); + } + } +} #[derive(Debug, Default)] struct FilesData { changed: HashSet, deleted: HashSet, + in_flight: Option, + next_generation: u32, + aggregate_scheduled: bool, + paused: bool, } impl FilesData { fn is_empty(&self) -> bool { self.changed.is_empty() && self.deleted.is_empty() } + + fn record(&mut self, path: String, kind: FsEventKind) { + match kind { + FsEventKind::Change | FsEventKind::Create => { + self.deleted.remove(&path); + self.changed.insert(path); + } + FsEventKind::Remove => { + self.changed.remove(&path); + self.deleted.insert(path); + } + } + } + + fn next_generation(&mut self) -> u32 { + let generation = self.next_generation; + self.next_generation = self.next_generation.wrapping_add(1); + generation + } + + fn claim(&mut self) -> AggregatedFiles { + let files = AggregatedFiles { + changed: std::mem::take(&mut self.changed), + deleted: std::mem::take(&mut self.deleted), + generation: self.next_generation(), + }; + debug_assert!(self.in_flight.is_none()); + self.in_flight = Some(files.clone()); + files + } + + fn drain(&mut self) -> AggregatedFiles { + let generation = self.next_generation(); + let mut files = self.in_flight.take().unwrap_or_else(|| AggregatedFiles { + changed: Default::default(), + deleted: Default::default(), + generation, + }); + files.generation = generation; + files.merge( + std::mem::take(&mut self.changed), + std::mem::take(&mut self.deleted), + ); + files + } + + fn acknowledge(&mut self, generation: u32) -> bool { + if self + .in_flight + .as_ref() + .is_some_and(|files| files.generation == generation) + { + self.in_flight = None; + true + } else { + false + } + } + + fn schedule_if_needed(&mut self) -> bool { + if self.paused || self.aggregate_scheduled || self.in_flight.is_some() || self.is_empty() { + return false; + } + self.aggregate_scheduled = true; + true + } +} + +fn lock_pending(files: &PendingFiles) -> SyncMutexGuard<'_, FilesData> { + files + .lock() + .expect("pending watcher events mutex should not be poisoned") } /// `WatcherExecutor` is responsible for managing the execution of file system event handlers, @@ -34,13 +128,11 @@ impl FilesData { pub struct Executor { aggregate_timeout: u32, rx: ThreadSafetyReceiver, - files_data: ThreadSafety, + files_data: PendingFiles, exec_aggregate_tx: UnboundedSender, exec_aggregate_rx: ThreadSafetyReceiver, exec_tx: UnboundedSender, exec_rx: ThreadSafetyReceiver, - paused: Arc, - aggregate_running: Arc, start_waiting: bool, execute_handle: Option>, execute_aggregate_handle: Option>, @@ -72,8 +164,6 @@ impl Executor { Self { start_waiting: false, - aggregate_running: Arc::new(AtomicBool::new(false)), - paused: Arc::new(AtomicBool::new(false)), rx: Arc::new(Mutex::new(rx)), files_data: Default::default(), exec_aggregate_tx, @@ -86,11 +176,32 @@ impl Executor { } } - /// Pause the aggregate executor, it will not execute the event handler until resume. + /// Pauses aggregate delivery. Raw events continue accumulating until resume. pub fn pause(&self) { - self - .paused - .store(true, std::sync::atomic::Ordering::Relaxed); + lock_pending(&self.files_data).paused = true; + } + + /// Atomically pauses aggregate delivery and consumes its pending events. + /// Consumed events will not be delivered to that handler later. + pub fn take_pending_events(&self) -> (HashSet, HashSet, u32) { + let mut files = lock_pending(&self.files_data); + files.paused = true; + files.aggregate_scheduled = false; + let files = files.drain(); + (files.changed, files.deleted, files.generation) + } + + pub fn acknowledge_pending_events(&self, generation: u32) { + let should_aggregate = { + let mut files = lock_pending(&self.files_data); + if files.acknowledge(generation) { + files.aggregate_scheduled = false; + } + files.schedule_if_needed() + }; + if should_aggregate { + let _ = self.exec_aggregate_tx.send(ExecAggregateEvent::Execute); + } } /// Abort all executor. @@ -103,7 +214,7 @@ impl Executor { if let Err(err) = execute_aggregate_handle.await { debug_assert!(err.is_cancelled()); } - self.aggregate_running.store(false, Ordering::Relaxed); + lock_pending(&self.files_data).aggregate_scheduled = false; } if let Some(execute_handle) = std::mem::take(&mut self.execute_handle) { execute_handle.abort(); @@ -131,27 +242,18 @@ impl Executor { let rx = Arc::clone(&self.rx); let exec_aggregate_tx = self.exec_aggregate_tx.clone(); let exec_tx = self.exec_tx.clone(); - let paused = Arc::clone(&self.paused); - let aggregate_running = Arc::clone(&self.aggregate_running); let future = async move { while let Some(events) = rx.lock().await.recv().await { - for event in &events { - let path = event.path.to_string_lossy().to_string(); - match event.kind { - FsEventKind::Change => { - files_data.lock().await.changed.insert(path); - } - FsEventKind::Remove => { - files_data.lock().await.deleted.insert(path); - } - FsEventKind::Create => { - files_data.lock().await.changed.insert(path); - } + let should_aggregate = { + let mut files_data = lock_pending(&files_data); + for event in &events { + files_data.record(event.path.to_string_lossy().to_string(), event.kind); } - } + files_data.schedule_if_needed() + }; - if !paused.load(Ordering::Relaxed) && !aggregate_running.load(Ordering::Relaxed) { + if should_aggregate { let _ = exec_aggregate_tx.send(ExecAggregateEvent::Execute); } @@ -166,7 +268,6 @@ impl Executor { self.start_waiting = true; } - self.paused.store(false, Ordering::Relaxed); // abort the previous handlers if they exist self.abort().await; @@ -177,7 +278,12 @@ impl Executor { // indefinitely — the event loop already processed them (added to files_data) // but skipped sending Execute because paused was true. No future OS event // will re-deliver them, so we must kick the aggregate task ourselves. - if !self.files_data.lock().await.is_empty() { + let should_aggregate = { + let mut files = lock_pending(&self.files_data); + files.paused = false; + files.schedule_if_needed() + }; + if should_aggregate { let _ = self.exec_aggregate_tx.send(ExecAggregateEvent::Execute); } } @@ -191,8 +297,8 @@ impl Executor { event_aggregate_handler, Arc::clone(&self.exec_aggregate_rx), Arc::clone(&self.files_data), + self.exec_aggregate_tx.clone(), self.aggregate_timeout as u64, - Arc::clone(&self.aggregate_running), )); self.execute_handle = Some(create_execute_task( @@ -239,9 +345,9 @@ fn create_execute_task( fn create_execute_aggregate_task( event_handler: Box, exec_aggregate_rx: ThreadSafetyReceiver, - files: ThreadSafety, + pending_files: PendingFiles, + exec_aggregate_tx: UnboundedSender, aggregate_timeout: u64, - running: Arc, ) -> tokio::task::JoinHandle<()> { let future = async move { loop { @@ -256,23 +362,43 @@ fn create_execute_aggregate_task( }; if let ExecAggregateEvent::Execute = aggregate_rx { - running.store(true, Ordering::Relaxed); // Wait for the aggregate timeout before executing the handler tokio::time::sleep(tokio::time::Duration::from_millis(aggregate_timeout)).await; // Get the files to process let files = { - let mut files = files.lock().await; + let mut files = lock_pending(&pending_files); + if files.paused { + files.aggregate_scheduled = false; + continue; + } + // A stale queued Execute must not replace a batch that is still + // waiting for the JS consumer to acknowledge or drain it. + if files.in_flight.is_some() { + continue; + } if files.is_empty() { - running.store(false, Ordering::Relaxed); + files.aggregate_scheduled = false; continue; } - std::mem::take(&mut *files) + files.claim() }; // Call the event handler with the changed and deleted files - event_handler.on_event_handle(files.changed, files.deleted); - running.store(false, Ordering::Relaxed); + let generation = files.generation; + let defer_acknowledgement = + event_handler.on_event_handle_with_generation(files.changed, files.deleted, generation); + if !defer_acknowledgement { + let should_aggregate = { + let mut files = lock_pending(&pending_files); + files.acknowledge(generation); + files.aggregate_scheduled = false; + files.schedule_if_needed() + }; + if should_aggregate { + let _ = exec_aggregate_tx.send(ExecAggregateEvent::Execute); + } + } } } }; diff --git a/crates/rspack_watcher/src/lib.rs b/crates/rspack_watcher/src/lib.rs index c3a97788f2a7..1226345eacfc 100644 --- a/crates/rspack_watcher/src/lib.rs +++ b/crates/rspack_watcher/src/lib.rs @@ -52,6 +52,18 @@ pub trait EventAggregateHandler { /// Handle a batch of file system events. fn on_event_handle(&self, _changed_files: HashSet, _deleted_files: HashSet); + /// Handle a versioned batch. Return `true` only when delivery is asynchronous; + /// the caller must then acknowledge the generation after delivery. + fn on_event_handle_with_generation( + &self, + changed_files: HashSet, + deleted_files: HashSet, + _generation: u32, + ) -> bool { + self.on_event_handle(changed_files, deleted_files); + false + } + /// Handle an error that occurs during file system watching. fn on_error(&self, _error: rspack_error::Error) { // Default implementation does nothing. @@ -165,13 +177,24 @@ impl FsWatcher { } } - /// Pauses the file system watcher, stopping the execution of the event loop. + /// Pauses aggregate delivery. Raw events continue accumulating until the next watch cycle. pub fn pause(&self) -> Result<()> { self.executor.pause(); Ok(()) } + /// Atomically pauses aggregate delivery and consumes its pending events. + /// Consumed events will not be delivered to that handler later. + pub fn take_pending_events(&self) -> (HashSet, HashSet, u32) { + self.executor.take_pending_events() + } + + /// Acknowledges asynchronous delivery of an aggregate generation. + pub fn acknowledge_pending_events(&self, generation: u32) { + self.executor.acknowledge_pending_events(generation); + } + fn wait_for_event( &mut self, files: (impl Iterator, impl Iterator), diff --git a/crates/rspack_watcher/tests/pending_events.rs b/crates/rspack_watcher/tests/pending_events.rs new file mode 100644 index 000000000000..fb1aca70f8b6 --- /dev/null +++ b/crates/rspack_watcher/tests/pending_events.rs @@ -0,0 +1,207 @@ +use std::time::SystemTime; + +use rspack_paths::ArcPath; +use rspack_util::fx_hash::FxHashSet; +use rspack_watcher::{ + EventAggregateHandler, EventHandler, FsEventKind, FsWatcher, FsWatcherOptions, +}; +use tempfile::TempDir; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; + +type Aggregate = (FxHashSet, FxHashSet, u32); + +struct AggregateHandler(UnboundedSender); + +impl EventAggregateHandler for AggregateHandler { + fn on_event_handle(&self, _changed: FxHashSet, _removed: FxHashSet) { + unreachable!("the executor should provide an aggregate generation"); + } + + fn on_event_handle_with_generation( + &self, + changed: FxHashSet, + removed: FxHashSet, + generation: u32, + ) -> bool { + let _ = self.0.send((changed, removed, generation)); + true + } +} + +struct ChangeHandler(UnboundedSender<(FsEventKind, String)>); + +impl EventHandler for ChangeHandler { + fn on_change(&self, path: String) -> rspack_error::Result<()> { + let _ = self.0.send((FsEventKind::Change, path)); + Ok(()) + } + + fn on_delete(&self, path: String) -> rspack_error::Result<()> { + let _ = self.0.send((FsEventKind::Remove, path)); + Ok(()) + } +} + +fn empty_paths() -> (std::iter::Empty, std::iter::Empty) { + (std::iter::empty(), std::iter::empty()) +} + +fn path_string(path: &ArcPath) -> String { + path.as_ref().to_string_lossy().into_owned() +} + +async fn watch( + watcher: &mut FsWatcher, + paths: &[ArcPath], + aggregate_tx: &UnboundedSender, + change_tx: &UnboundedSender<(FsEventKind, String)>, +) { + watcher + .watch( + empty_paths(), + empty_paths(), + (paths.iter().cloned(), std::iter::empty()), + SystemTime::now(), + Box::new(AggregateHandler(aggregate_tx.clone())), + Box::new(ChangeHandler(change_tx.clone())), + ) + .await; +} + +async fn receive_changes( + receiver: &mut UnboundedReceiver<(FsEventKind, String)>, + count: usize, +) -> Vec<(FsEventKind, String)> { + let mut events = Vec::with_capacity(count); + for _ in 0..count { + let event = tokio::time::timeout(std::time::Duration::from_secs(10), receiver.recv()) + .await + .expect("watcher event should arrive") + .expect("watcher event channel should stay open"); + events.push(event); + } + events +} + +async fn receive_aggregate(receiver: &mut UnboundedReceiver) -> Aggregate { + tokio::time::timeout(std::time::Duration::from_secs(10), receiver.recv()) + .await + .expect("aggregate should arrive") + .expect("aggregate channel should stay open") +} + +#[tokio::test] +async fn pending_events_are_consumed_once_without_aggregate_replay() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let changed_path = ArcPath::from(temp_dir.path().join("changed.js")); + let removed_path = ArcPath::from(temp_dir.path().join("removed.js")); + + let mut watcher = FsWatcher::new( + FsWatcherOptions { + aggregate_timeout: Some(200), + ..Default::default() + }, + Default::default(), + ); + let (aggregate_tx, mut aggregate_rx) = unbounded_channel(); + let (change_tx, mut change_rx) = unbounded_channel(); + let paths = [changed_path.clone(), removed_path.clone()]; + + watch(&mut watcher, &paths, &aggregate_tx, &change_tx).await; + watcher.trigger_event(&changed_path, FsEventKind::Create); + watcher.trigger_event(&removed_path, FsEventKind::Remove); + assert_eq!( + receive_changes(&mut change_rx, 2).await, + [ + (FsEventKind::Change, path_string(&changed_path)), + (FsEventKind::Remove, path_string(&removed_path)), + ], + ); + watcher.pause().expect("watcher should pause"); + + let (changes, removals, generation) = watcher.take_pending_events(); + assert_eq!(changes, FxHashSet::from_iter([path_string(&changed_path)])); + assert_eq!(removals, FxHashSet::from_iter([path_string(&removed_path)])); + watcher.trigger_event(&removed_path, FsEventKind::Create); + assert_eq!( + receive_changes(&mut change_rx, 1).await, + [(FsEventKind::Change, path_string(&removed_path))], + ); + let (changes, removals, next_generation) = watcher.take_pending_events(); + assert_eq!(changes, FxHashSet::from_iter([path_string(&removed_path)])); + assert!(removals.is_empty()); + assert!(next_generation > generation); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(500), aggregate_rx.recv()) + .await + .is_err(), + "a scheduled aggregate must not claim events after pause", + ); + + watch(&mut watcher, &paths, &aggregate_tx, &change_tx).await; + assert!( + tokio::time::timeout(std::time::Duration::from_millis(500), aggregate_rx.recv()) + .await + .is_err(), + "consumed events must not be replayed by the aggregate handler", + ); + + watcher.trigger_event(&changed_path, FsEventKind::Create); + watcher.trigger_event(&changed_path, FsEventKind::Remove); + watcher.trigger_event(&changed_path, FsEventKind::Create); + assert_eq!( + receive_changes(&mut change_rx, 3).await, + [ + (FsEventKind::Change, path_string(&changed_path)), + (FsEventKind::Remove, path_string(&changed_path)), + (FsEventKind::Change, path_string(&changed_path)), + ], + ); + + let (changes, removals, aggregate_generation) = receive_aggregate(&mut aggregate_rx).await; + assert_eq!(changes, FxHashSet::from_iter([path_string(&changed_path)])); + assert!(removals.is_empty()); + watcher.acknowledge_pending_events(aggregate_generation); + + let (changes, removals, drained_generation) = watcher.take_pending_events(); + assert!(changes.is_empty()); + assert!(removals.is_empty()); + assert!(drained_generation > aggregate_generation); + + watch(&mut watcher, &paths, &aggregate_tx, &change_tx).await; + watcher.trigger_event(&changed_path, FsEventKind::Create); + assert_eq!( + receive_changes(&mut change_rx, 1).await, + [(FsEventKind::Change, path_string(&changed_path))], + ); + let (changes, removals, aggregate_generation) = receive_aggregate(&mut aggregate_rx).await; + assert_eq!(changes, FxHashSet::from_iter([path_string(&changed_path)])); + assert!(removals.is_empty()); + + watcher.trigger_event(&changed_path, FsEventKind::Remove); + watcher.trigger_event(&removed_path, FsEventKind::Create); + assert_eq!( + receive_changes(&mut change_rx, 2).await, + [ + (FsEventKind::Remove, path_string(&changed_path)), + (FsEventKind::Change, path_string(&removed_path)), + ], + ); + + // The aggregate handler queues delivery asynchronously. Until JS acknowledges + // it, a synchronous drain must recover that claimed batch and supersede the + // late callback generation. + let (changes, removals, drained_generation) = watcher.take_pending_events(); + assert_eq!(changes, FxHashSet::from_iter([path_string(&removed_path)])); + assert_eq!(removals, FxHashSet::from_iter([path_string(&changed_path)])); + assert!(drained_generation > aggregate_generation); + watcher.acknowledge_pending_events(aggregate_generation); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(500), aggregate_rx.recv()) + .await + .is_err(), + "coalesced events should produce one aggregate", + ); + + watcher.close().await.expect("watcher should close"); +} diff --git a/packages/rspack/module.d.ts b/packages/rspack/module.d.ts index f00dbf6c28ef..f662d45a947e 100644 --- a/packages/rspack/module.d.ts +++ b/packages/rspack/module.d.ts @@ -104,6 +104,8 @@ declare namespace Rspack { ignoreUnaccepted?: boolean; ignoreDeclined?: boolean; ignoreErrored?: boolean; + /** Keep factories for modules removed from the compilation available until the apply transaction settles. */ + preserveDisposedModuleFactories?: boolean; onDeclined?: (event: DeclinedEvent) => void; onUnaccepted?: (event: UnacceptedEvent) => void; onAccepted?: (event: AcceptedEvent) => void; diff --git a/packages/rspack/src/Compilation.ts b/packages/rspack/src/Compilation.ts index 4e56bbde525b..d88a7130413f 100644 --- a/packages/rspack/src/Compilation.ts +++ b/packages/rspack/src/Compilation.ts @@ -22,6 +22,8 @@ import binding from '@rspack/binding'; export type { AssetInfo } from '@rspack/binding'; +export type WatchInvalidationKind = 'lazy' | 'normal'; + import * as liteTapable from '@rspack/lite-tapable'; import type { Source } from 'webpack-sources'; import type { EntryOptions, EntryPlugin } from './builtin-plugin'; @@ -292,6 +294,11 @@ export class Compilation { needAdditionalPass: liteTapable.SyncBailHook<[], boolean>; }>; name?: string; + /** + * The invalidation that started this watch compilation. `normal` dominates + * mixed or coalesced invalidations; initial and non-watch compilations are undefined. + */ + readonly watchInvalidationKind: WatchInvalidationKind | undefined; startTime?: number; endTime?: number; compiler: Compiler; @@ -324,6 +331,7 @@ export class Compilation { constructor(compiler: Compiler, inner: JsCompilation) { this.#inner = inner; this.#shutdown = false; + this.watchInvalidationKind = compiler.__internal__watchInvalidationKind; const processAssetsHook = new liteTapable.AsyncSeriesHook([ 'assets', diff --git a/packages/rspack/src/Compiler.ts b/packages/rspack/src/Compiler.ts index d6510fc46fe7..15a728e20b54 100644 --- a/packages/rspack/src/Compiler.ts +++ b/packages/rspack/src/Compiler.ts @@ -20,7 +20,7 @@ import { } from './builtin-plugin'; import { canInherentFromParent } from './builtin-plugin/base'; import type { Chunk } from './Chunk'; -import type { CompilationParams } from './Compilation'; +import type { CompilationParams, WatchInvalidationKind } from './Compilation'; import { Compilation } from './Compilation'; import { ContextModuleFactory } from './ContextModuleFactory'; import type { @@ -167,6 +167,9 @@ class Compiler { infrastructureLogger: any; watching?: Watching; + /** @internal Set by Watching while a watch compilation is being created. */ + __internal__watchInvalidationKind?: WatchInvalidationKind; + inputFileSystem: InputFileSystem | null; intermediateFileSystem: IntermediateFileSystem | null; outputFileSystem: OutputFileSystem | null; diff --git a/packages/rspack/src/MultiCompiler.ts b/packages/rspack/src/MultiCompiler.ts index de6653cae949..0d0fc6b86c49 100644 --- a/packages/rspack/src/MultiCompiler.ts +++ b/packages/rspack/src/MultiCompiler.ts @@ -15,6 +15,7 @@ import type { CompilerHooks, RspackOptions, Stats, + WatchInvalidationKind, } from '.'; import type { WatchOptions } from './config'; import ConcurrentCompilationError from './error/ConcurrentCompilationError'; @@ -34,6 +35,7 @@ interface Node { parents: Node[]; setupResult?: T; result?: Stats; + parentInvalidationKind?: WatchInvalidationKind; state: | 'pending' | 'blocked' @@ -321,6 +323,7 @@ export class MultiCompiler { compiler: Compiler, res: SetupResult, done: liteTapable.Callback, + parentInvalidationKind?: WatchInvalidationKind, ) => void, callback: liteTapable.Callback, ): SetupResult[] { @@ -391,7 +394,15 @@ export class MultiCompiler { running--; if (node.state === 'running') { node.state = 'done'; + const invalidationKind = stats.compilation.watchInvalidationKind; for (const child of node.children) { + if ( + invalidationKind === 'normal' || + (invalidationKind === 'lazy' && + child.parentInvalidationKind === undefined) + ) { + child.parentInvalidationKind = invalidationKind; + } if (child.state === 'blocked') queue.enqueue(child); } } else if (node.state === 'running-outdated') { @@ -476,7 +487,9 @@ export class MultiCompiler { node.compiler, node.setupResult!, nodeDone.bind(null, node) as liteTapable.Callback, + node.parentInvalidationKind, ); + node.parentInvalidationKind = undefined; node.state = 'running'; } } @@ -531,9 +544,13 @@ export class MultiCompiler { } return watching; }, - (compiler, watching, _done) => { - if (compiler.watching !== watching) return; - if (!watching.running) watching.invalidate(); + (compiler, watching, _done, parentInvalidationKind) => { + if (compiler.watching !== watching || watching.running) return; + if (parentInvalidationKind) { + watching.__internal__invalidate(parentInvalidationKind); + } else { + watching.__internal__resumeFromMultiCompiler(); + } }, handler, ); diff --git a/packages/rspack/src/MultiWatching.ts b/packages/rspack/src/MultiWatching.ts index 6858ce3cdd95..e1ad37c6e660 100644 --- a/packages/rspack/src/MultiWatching.ts +++ b/packages/rspack/src/MultiWatching.ts @@ -9,6 +9,7 @@ */ import type { Callback } from '@rspack/lite-tapable'; +import type { WatchInvalidationKind } from './Compilation'; import type { MultiCompiler } from './MultiCompiler'; import asyncLib from './util/asyncLib'; import type { Watching } from './Watching'; @@ -26,17 +27,25 @@ class MultiWatching { this.compiler = compiler; } invalidate(callback?: Callback) { + this.__internal__invalidate('normal', callback); + } + + /** @internal Invalidates all child compilers with Rspack provenance. */ + __internal__invalidate( + kind: WatchInvalidationKind, + callback?: Callback, + ) { if (callback) { asyncLib.each( this.watchings, - (watching, callback) => watching.invalidate(callback), + (watching, callback) => watching.__internal__invalidate(kind, callback), // cannot be resolved without assertion // Type 'Error | null | undefined' is not assignable to type 'Error | null' callback as (err: Error | null | undefined) => void, ); } else { for (const watching of this.watchings) { - watching.invalidate(); + watching.__internal__invalidate(kind); } } } diff --git a/packages/rspack/src/NativeWatchFileSystem.ts b/packages/rspack/src/NativeWatchFileSystem.ts index cffde41a8a9e..7729fa56e59e 100644 --- a/packages/rspack/src/NativeWatchFileSystem.ts +++ b/packages/rspack/src/NativeWatchFileSystem.ts @@ -93,6 +93,19 @@ export default class NativeWatchFileSystem implements WatchFileSystem { return this.#watcher; } + #purge(changes: Iterable, removals: Iterable): void { + const fs = this.#inputFileSystem; + if (!fs.purge) { + return; + } + for (const item of changes) { + fs.purge(item); + } + for (const item of removals) { + fs.purge(item); + } + } + watch( files: Iterable & { added?: Iterable; @@ -153,6 +166,7 @@ export default class NativeWatchFileSystem implements WatchFileSystem { this.#inner?.triggerEvent(kind, path), ); this.#watcher = watcher; + let lastDrainedGeneration: number | undefined; nativeWatcher.watch( this.formatWatchDependencies(files), @@ -164,18 +178,18 @@ export default class NativeWatchFileSystem implements WatchFileSystem { callback(err, new Map(), new Map(), new Set(), new Set()); return; } + if ( + lastDrainedGeneration !== undefined && + result.generation <= lastDrainedGeneration + ) { + nativeWatcher.acknowledgePendingEvents(result.generation); + return; + } nativeWatcher.pause(); + nativeWatcher.acknowledgePendingEvents(result.generation); const changedFiles = result.changedFiles; const removedFiles = result.removedFiles; - if (this.#inputFileSystem?.purge) { - const fs = this.#inputFileSystem; - for (const item of changedFiles) { - fs.purge?.(item); - } - for (const item of removedFiles) { - fs.purge?.(item); - } - } + this.#purge(changedFiles, removedFiles); // TODO: add fileTimeInfoEntries and contextTimeInfoEntries const changes = new Set(changedFiles); const removals = new Set(removedFiles); @@ -223,12 +237,14 @@ export default class NativeWatchFileSystem implements WatchFileSystem { nativeWatcher.pause(); }, - getInfo() { - // This is a placeholder implementation. - // TODO: The actual implementation should return the current state of the watcher. + getInfo: () => { + const { changedFiles, removedFiles, generation } = + nativeWatcher.takePendingEvents(); + lastDrainedGeneration = generation; + this.#purge(changedFiles, removedFiles); return { - changes: new Set(), - removals: new Set(), + changes: new Set(changedFiles), + removals: new Set(removedFiles), fileTimeInfoEntries: new Map(), contextTimeInfoEntries: new Map(), }; diff --git a/packages/rspack/src/Watching.ts b/packages/rspack/src/Watching.ts index 9ef4243a3d99..53d799d73c69 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -11,6 +11,7 @@ import type { Callback } from '@rspack/lite-tapable'; import type { Compilation, Compiler } from '.'; import { Stats } from '.'; +import type { WatchInvalidationKind } from './Compilation'; import type { WatchOptions } from './config'; import type { FileSystemInfoEntry, Watcher } from './util/fs'; @@ -53,6 +54,7 @@ export class Watching { #closed: boolean; #collectedChangedFiles?: Set; #collectedRemovedFiles?: Set; + #pendingInvalidationKind?: WatchInvalidationKind; #pendingWatchDeps?: { file: PendingWatchDelta; context: PendingWatchDelta; @@ -131,6 +133,9 @@ export class Watching { this.compiler.removedFiles = undefined; return this.handler(err); } + if (changedFiles.size > 0 || removedFiles.size > 0) { + this.#recordInvalidation('normal'); + } this.#invalidate( fileTimeInfoEntries, contextTimeInfoEntries, @@ -140,6 +145,7 @@ export class Watching { this.onChange(); }, (fileName, changeTime) => { + this.#recordInvalidation('normal'); if (!this.#invalidReported) { this.#invalidReported = true; this.compiler.hooks.invalid.call(fileName, changeTime); @@ -159,6 +165,8 @@ export class Watching { const finalCallback = (err: Error | null) => { this.running = false; + this.#pendingInvalidationKind = undefined; + this.compiler.__internal__watchInvalidationKind = undefined; this.compiler.running = false; this.compiler.watching = undefined; this.compiler.watchMode = false; @@ -214,14 +222,40 @@ export class Watching { } } - invalidate(callback?: Callback) { - if (callback) { - this.callbacks.push(callback); - } + #notifyInvalid() { if (!this.#invalidReported) { this.#invalidReported = true; this.compiler.hooks.invalid.call(null, Date.now()); } + } + + #recordInvalidation(kind: WatchInvalidationKind) { + if (kind === 'normal' || this.#pendingInvalidationKind === undefined) { + this.#pendingInvalidationKind = kind; + } + } + + invalidate(callback?: Callback) { + this.__internal__invalidate('normal', callback); + } + + /** @internal Invalidates with provenance supplied by Rspack internals. */ + __internal__invalidate( + kind: WatchInvalidationKind, + callback?: Callback, + ) { + if (callback) { + this.callbacks.push(callback); + } + this.#recordInvalidation(kind); + this.#notifyInvalid(); + this.onChange(); + this.#invalidate(); + } + + /** @internal Resume an invalidation already recorded by MultiCompiler. */ + __internal__resumeFromMultiCompiler() { + this.#notifyInvalid(); this.onChange(); this.#invalidate(); } @@ -237,10 +271,8 @@ export class Watching { if (callback) { this.callbacks.push(callback); } - if (!this.#invalidReported) { - this.#invalidReported = true; - this.compiler.hooks.invalid.call(null, Date.now()); - } + this.#recordInvalidation('normal'); + this.#notifyInvalid(); this.onChange(); this.#invalidate(undefined, undefined, changedFiles, removedFiles); } @@ -302,11 +334,18 @@ export class Watching { } else if (this.pausedWatcher) { const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } = this.pausedWatcher.getInfo(); + if (changes.size > 0 || removals.size > 0) { + this.#recordInvalidation('normal'); + } this.#mergeWithCollected(changes, removals); this.compiler.fileTimestamps = fileTimeInfoEntries; this.compiler.contextTimestamps = contextTimeInfoEntries; } + this.compiler.__internal__watchInvalidationKind = + this.#pendingInvalidationKind; + this.#pendingInvalidationKind = undefined; + this.compiler.modifiedFiles = this.#collectedChangedFiles; this.compiler.removedFiles = this.#collectedRemovedFiles; this.#collectedChangedFiles = undefined; @@ -394,6 +433,8 @@ export class Watching { }; if (error) { + this.#pendingInvalidationKind = undefined; + this.compiler.__internal__watchInvalidationKind = undefined; return handleError(error); } @@ -411,7 +452,10 @@ export class Watching { ) { // Coalesced rebuild: the `watch()` delivery below is skipped, so carry // this build's deltas forward to the next delivered `watch()`. See #12904. - if (compilation) this.#accumulateWatchDeps(compilation); + this.#accumulateWatchDeps(compilation); + if (compilation.watchInvalidationKind) { + this.#recordInvalidation(compilation.watchInvalidationKind); + } this.#go(); return; } @@ -423,6 +467,7 @@ export class Watching { compilation.endTime = Date.now(); const cbs = this.callbacks; this.callbacks = []; + this.compiler.__internal__watchInvalidationKind = undefined; this.compiler.hooks.done.callAsync(stats, (err) => { if (err) return handleError(err, cbs); diff --git a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts index 28895e448504..bcf68e4b9096 100644 --- a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts +++ b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts @@ -261,7 +261,7 @@ const lazyCompilationMiddlewareInternal = ( } if (moduleActivated.length && compiler.watching) { - compiler.watching.invalidate(); + compiler.watching.__internal__invalidate('lazy'); } res.writeHead(200); diff --git a/packages/rspack/src/exports.ts b/packages/rspack/src/exports.ts index 628c7d30601d..bd9777f82fe3 100644 --- a/packages/rspack/src/exports.ts +++ b/packages/rspack/src/exports.ts @@ -10,6 +10,7 @@ export type { CompilationParams, LogEntry, PathData, + WatchInvalidationKind, } from './Compilation'; export { Compilation } from './Compilation'; export { Compiler, type CompilerHooks } from './Compiler'; diff --git a/tests/rspack-test/NativeWatcherGeneration.test.js b/tests/rspack-test/NativeWatcherGeneration.test.js new file mode 100644 index 000000000000..e8faf9fbf81c --- /dev/null +++ b/tests/rspack-test/NativeWatcherGeneration.test.js @@ -0,0 +1,92 @@ +const { rspack } = require("@rspack/core"); + +class FakeNativeWatcher { + acknowledgements = []; + pauses = 0; + pendingDrain = { + changedFiles: [], + removedFiles: [], + generation: 0 + }; + + watch(_files, _directories, _missing, _startTime, onAggregate) { + this.onAggregate = onAggregate; + } + + takePendingEvents() { + return this.pendingDrain; + } + + acknowledgePendingEvents(generation) { + this.acknowledgements.push(generation); + } + + pause() { + this.pauses++; + } + + close() { + return Promise.resolve(); + } +} + +const dependencies = () => + Object.assign([], { + added: [], + removed: [] + }); + +describe("NativeWatchFileSystem aggregate generations", () => { + it("suppresses a callback superseded by a synchronous native drain", () => { + const nativeWatcher = new FakeNativeWatcher(); + const purged = []; + const callbackChanges = []; + const compiler = rspack({ + context: __dirname, + entry: __filename, + experiments: { nativeWatcher: true } + }); + compiler.inputFileSystem.purge = path => purged.push(path); + const watchFileSystem = compiler.watchFileSystem; + watchFileSystem.getNativeWatcher = () => nativeWatcher; + + const watcher = watchFileSystem.watch( + dependencies(), + dependencies(), + dependencies(), + Date.now(), + {}, + (_error, _fileTimes, _contextTimes, changes) => + callbackChanges.push(changes), + () => {} + ); + + nativeWatcher.pendingDrain = { + changedFiles: ["/changed"], + removedFiles: [], + generation: 2 + }; + expect(watcher.getInfo().changes).toEqual(new Set(["/changed"])); + expect(purged).toEqual(["/changed"]); + + nativeWatcher.onAggregate(null, { + changedFiles: ["/changed"], + removedFiles: [], + generation: 1 + }); + expect(nativeWatcher.acknowledgements).toEqual([1]); + expect(nativeWatcher.pauses).toBe(0); + expect(purged).toEqual(["/changed"]); + expect(callbackChanges).toEqual([]); + + nativeWatcher.onAggregate(null, { + changedFiles: ["/next"], + removedFiles: [], + generation: 3 + }); + expect(nativeWatcher.acknowledgements).toEqual([1, 3]); + expect(nativeWatcher.pauses).toBe(1); + expect(purged).toEqual(["/changed", "/next"]); + expect(callbackChanges).toEqual([new Set(["/next"])]); + }); +}); diff --git a/tests/rspack-test/compilerCases/watching.js b/tests/rspack-test/compilerCases/watching.js index 1cc508ed245e..67a84f7311c4 100644 --- a/tests/rspack-test/compilerCases/watching.js +++ b/tests/rspack-test/compilerCases/watching.js @@ -1,4 +1,6 @@ const { createFsFromVolume, Volume } = require("memfs"); +const { lazyCompilationMiddleware } = require("@rspack/core"); +const path = require("node:path"); const { start } = require("@rspack/test-tools/helper/legacy/deprecationTracking"); let tracker = null; @@ -44,6 +46,120 @@ module.exports = [{ }); }); }, +}, { + description: "should snapshot lazy compilation invalidation provenance per watch cycle", + options(context) { + return { + entry: "./c", + lazyCompilation: { + entries: false, + imports: false, + }, + }; + }, + async compiler(context, compiler) { + compiler.outputFileSystem = createFsFromVolume(new Volume()); + }, + async build(context, compiler) { + const cycles = []; + let coalesceNormalInvalidation = false; + let emitNormalFileChange; + let watching; + compiler.watchFileSystem = { + watch( + files, + dirs, + missing, + startTime, + options, + callback, + callbackUndelayed, + ) { + emitNormalFileChange = () => { + const file = path.join(compiler.context, "c.js"); + callbackUndelayed(file, Date.now()); + callback(null, new Map(), new Map(), new Set([file]), new Set()); + }; + return { + close() {}, + getInfo() { + return { + changes: new Set(), + removals: new Set(), + fileTimeInfoEntries: new Map(), + contextTimeInfoEntries: new Map(), + }; + }, + pause() {}, + }; + }, + }; + compiler.hooks.thisCompilation.tap( + "test lazy invalidation provenance", + compilation => { + cycles.push(compilation.watchInvalidationKind); + }, + ); + compiler.hooks.make.tapAsync( + "coalesce normal invalidation", + (compilation, callback) => { + if ( + coalesceNormalInvalidation && + compilation.watchInvalidationKind === "lazy" + ) { + coalesceNormalInvalidation = false; + emitNormalFileChange(); + } + callback(); + }, + ); + + const middleware = lazyCompilationMiddleware(compiler); + const activate = module => + new Promise((resolve, reject) => { + middleware( + { + body: [module], + method: "POST", + url: "/_rspack/lazy/trigger", + }, + { + end: resolve, + write() {}, + writeHead(status) { + expect(status).toBe(200); + }, + }, + reject, + ).catch(reject); + }); + + return new Promise((resolve, reject) => { + let builds = 0; + watching = compiler.watch({}, err => { + if (err) return reject(err); + builds++; + if (builds === 1) { + setImmediate(() => activate("first-lazy-module").catch(reject)); + return; + } + if (builds === 2) { + coalesceNormalInvalidation = true; + setImmediate(() => activate("coalesced-lazy-module").catch(reject)); + return; + } + if (builds === 3) { + try { + expect(cycles).toEqual([undefined, "lazy", "lazy", "normal"]); + } catch (error) { + watching.close(() => reject(error)); + return; + } + watching.close(error => (error ? reject(error) : resolve())); + } + }); + }); + }, }, { description: "should deprecate when watch option is used without callback", options(context) { diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..68e7f02890e9 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/0.snap.txt @@ -0,0 +1,13 @@ +# Case entry-filename-full-hash: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: async_js.js +- Bundle: main.CURRENT_HASH.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/1.snap.txt new file mode 100644 index 000000000000..7742f93d4cfe --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/__snapshots__/web/1.snap.txt @@ -0,0 +1,49 @@ +# Case entry-filename-full-hash: Step 1 + +## Changed Files +- value.js + +## Asset Files +- Bundle: async_js.js +- Bundle: main.CURRENT_HASH.js +- Manifest: main.LAST_HASH.hot-update.json, size: 28 +- Update: main.LAST_HASH.hot-update.js, size: 219 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["main"],"r":[],"m":[]} +``` + + +## Update + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./value.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash + +#### Changed Content +```js +self["rspackHotUpdate"]("main", { +"./value.js"(module) { +module.exports = "b"; + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); + +} +); +``` \ No newline at end of file diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/async.js b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/async.js new file mode 100644 index 000000000000..5db290cb0299 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/async.js @@ -0,0 +1 @@ +export const value = "async"; diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/index.js b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/index.js new file mode 100644 index 000000000000..8bbd6ef5b217 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/index.js @@ -0,0 +1,13 @@ +let value = require("./value"); + +it("should resolve an entry filename containing the full hash", async () => { + expect(value).toBe("a"); + expect((await import("./async")).value).toBe("async"); + expect(__webpack_require__.u("main")).toMatch(/^main\.[a-f0-9]+\.js$/); + await NEXT_HMR(); + expect(value).toBe("b"); +}); + +module.hot.accept("./value", () => { + value = require("./value"); +}); diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/rspack.config.js b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/rspack.config.js new file mode 100644 index 000000000000..971fa59fe284 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/rspack.config.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = { + entry: { + main: { + import: './index.js', + filename: '[name].[fullhash].js', + }, + }, + optimization: { + chunkIds: 'named', + }, + output: { + filename: '[name].js', + chunkFilename: '[name].js', + }, +}; diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/test.filter.js b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/test.filter.js new file mode 100644 index 000000000000..b779a1246dc5 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/test.filter.js @@ -0,0 +1,2 @@ +module.exports = ({ rspackOptions, target }) => + target === "web" && rspackOptions?.experiments?.runtimeMode !== "rspack"; diff --git a/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/value.js b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/value.js new file mode 100644 index 000000000000..a5a131568b15 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/entry-filename-full-hash/value.js @@ -0,0 +1,3 @@ +module.exports = "a"; +--- +module.exports = "b"; diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..3e04e764f82e --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/0.snap.txt @@ -0,0 +1,13 @@ +# Case initial-chunks-hmr: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: async/initial_js.js +- Bundle: main.CURRENT_HASH.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt new file mode 100644 index 000000000000..5036d88048fe --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt @@ -0,0 +1,780 @@ +# Case initial-chunks-hmr: Step 1 + +## Changed Files +- index.js +- initial.js + +## Asset Files +- Bundle: async/initial_js.js +- Bundle: lib.CURRENT_HASH.js +- Bundle: main.CURRENT_HASH.js +- Manifest: main.LAST_HASH.hot-update.json, size: 41 +- Update: initial_js.LAST_HASH.hot-update.js, size: 332 +- Update: main.LAST_HASH.hot-update.js, size: 21508 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["initial_js","main"],"r":[],"m":[]} +``` + + +## Update + + +### initial_js.LAST_HASH.hot-update.js + +#### Changed Modules +- ./initial.js + +#### Changed Runtime Modules + + +#### Changed Content +```js +"use strict"; +self["rspackHotUpdate"]("initial_js", { +"./initial.js"(__unused_rspack_module, __webpack_exports__, __webpack_require__) { +__webpack_require__.r(__webpack_exports__); +__webpack_require__.d(__webpack_exports__, { + value: () => (value) +}); +__webpack_require__("./node_modules/lib-js/b.js"); +const value = "b"; + + +}, + +}); +``` + + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./index.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash +- webpack/runtime/jsonp_chunk_loading +- webpack/runtime/on_chunk_loaded + +#### Changed Content +```js +"use strict"; +self["rspackHotUpdate"]("main", { +"./index.js"(__unused_rspack_module, __webpack_exports__, __webpack_require__) { +__webpack_require__.r(__webpack_exports__); +/* import */ var lib_js_a__rspack_import_0 = __webpack_require__("./node_modules/lib-js/a.js"); + + +it("should work if there are new initial chunks", async () => { + expect((await Promise.all(/* import() */ [__webpack_require__.e("lib"), __webpack_require__.e("initial_js")]).then(__webpack_require__.bind(__webpack_require__, "./initial.js"))).value).toBe("b"); +}); + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); +// webpack/runtime/on_chunk_loaded +(() => { +var deferred = []; +__webpack_require__.O = (result, chunkIds, fn, priority) => { + if (chunkIds) { + priority = priority || 0; + for (var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) + deferred[i] = deferred[i - 1]; + deferred[i] = [chunkIds, fn, priority]; + return; + } + var notFulfilled = Infinity; + for (var i = 0; i < deferred.length; i++) { + var [chunkIds, fn, priority] = deferred[i]; + var fulfilled = true; + for (var j = 0; j < chunkIds.length; j++) { + if ( + (priority & (1 === 0) || notFulfilled >= priority) && + Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j]))) + ) { + chunkIds.splice(j--, 1); + } else { + fulfilled = false; + if (priority < notFulfilled) notFulfilled = priority; + } + } + if (fulfilled) { + deferred.splice(i--, 1); + var r = fn(); + if (r !== undefined) result = r; + } + } + return result; +}; + +})(); +// webpack/runtime/jsonp_chunk_loading +(() => { + + // object to store loaded and loading chunks + // undefined = chunk not loaded, null = chunk preloaded/prefetched + // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded + var jsonpInstalledChunks = __webpack_require__.hmrS_jsonp = __webpack_require__.hmrS_jsonp || {"main": 0,}; + + __webpack_require__.f.j = function (chunkId, promises) { + // JSONP chunk loading for javascript +var installedChunkData = __webpack_require__.o(jsonpInstalledChunks, chunkId) + ? jsonpInstalledChunks[chunkId] + : undefined; +if (installedChunkData !== 0) { + // 0 means "already installed". + + // a Promise means "currently loading". + if (installedChunkData) { + promises.push(installedChunkData[2]); + } else { + if (true) { + // setup Promise in chunk cache + var promise = new Promise((resolve, reject) => (installedChunkData = jsonpInstalledChunks[chunkId] = [resolve, reject])); + promises.push((installedChunkData[2] = promise)); + + // start chunk loading + var url = __webpack_require__.p + __webpack_require__.u(chunkId); + // create error before stack unwound to get useful stacktrace later + var error = new Error(); + var loadingEnded = function (event) { + if (__webpack_require__.o(jsonpInstalledChunks, chunkId)) { + installedChunkData = jsonpInstalledChunks[chunkId]; + if (installedChunkData !== 0) jsonpInstalledChunks[chunkId] = undefined; + if (installedChunkData) { + var errorType = + event && (event.type === 'load' ? 'missing' : event.type); + var realSrc = event && event.target && event.target.src; + error.message = + 'Loading chunk ' + + chunkId + + ' failed.\n(' + + errorType + + ': ' + + realSrc + + ')'; + error.name = 'ChunkLoadError'; + error.type = errorType; + error.request = realSrc; + installedChunkData[1](error); + } + } + }; + __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId); + } + } +} + + } + var hotCurrentUpdatedModulesList; +var hotWaitingUpdateResolves = {}; +function jsonpLoadUpdateChunk(chunkId, updatedModulesList) { + hotCurrentUpdatedModulesList = updatedModulesList; + return new Promise((resolve, reject) => { + hotWaitingUpdateResolves[chunkId] = resolve; + // start update chunk loading + var url = __webpack_require__.p + __webpack_require__.hu(chunkId); + // create error before stack unwound to get useful stacktrace later + var error = new Error(); + var loadingEnded = (event) => { + if (hotWaitingUpdateResolves[chunkId]) { + hotWaitingUpdateResolves[chunkId] = undefined; + var errorType = + event && (event.type === 'load' ? 'missing' : event.type); + var realSrc = event && event.target && event.target.src; + error.message = + 'Loading hot update chunk ' + + chunkId + + ' failed.\n(' + + errorType + + ': ' + + realSrc + + ')'; + error.name = 'ChunkLoadError'; + error.type = errorType; + error.request = realSrc; + reject(error); + } + }; + __webpack_require__.l(url, loadingEnded); + }); +} + +self["rspackHotUpdate"] = (chunkId, moreModules, runtime) => { + for (var moduleId in moreModules) { + if (__webpack_require__.o(moreModules, moduleId)) { + hotCurrentUpdate[moduleId] = moreModules[moduleId]; + if (hotCurrentUpdatedModulesList) hotCurrentUpdatedModulesList.push(moduleId); + } + } + if (runtime) hotCurrentUpdateRuntime.push(runtime); + if (hotWaitingUpdateResolves[chunkId]) { + hotWaitingUpdateResolves[chunkId](); + hotWaitingUpdateResolves[chunkId] = undefined; + } +}; +var hotCurrentUpdateChunks; +var hotCurrentUpdate; +var hotCurrentUpdateRemovedChunks; +var hotCurrentUpdateRuntime; +function hotApplyHandler(options, moduleFactoryTransaction) { + if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; + hotCurrentUpdateChunks = undefined; + function getAffectedModuleEffects(updateModuleId) { + var outdatedModules = [updateModuleId]; + var outdatedDependencies = {}; + var queue = outdatedModules.map(function (id) { + return { + chain: [id], + id: id + }; + }); + while (queue.length > 0) { + var queueItem = queue.pop(); + var moduleId = queueItem.id; + var chain = queueItem.chain; + var module = __webpack_require__.c[moduleId]; + if ( + !module || + (module.hot._selfAccepted && !module.hot._selfInvalidated) + ) { + continue; + } + + if (module.hot._selfDeclined) { + return { + type: "self-declined", + chain: chain, + moduleId: moduleId + }; + } + + if (module.hot._main) { + return { + type: "unaccepted", + chain: chain, + moduleId: moduleId + }; + } + + for (var i = 0; i < module.parents.length; i++) { + var parentId = module.parents[i]; + var parent = __webpack_require__.c[parentId]; + if (!parent) { + continue; + } + if (parent.hot._declinedDependencies[moduleId]) { + return { + type: "declined", + chain: chain.concat([parentId]), + moduleId: moduleId, + parentId: parentId + }; + } + if (outdatedModules.indexOf(parentId) !== -1) { + continue; + } + if (parent.hot._acceptedDependencies[moduleId]) { + if (!outdatedDependencies[parentId]) { + outdatedDependencies[parentId] = []; + } + addAllToSet(outdatedDependencies[parentId], [moduleId]); + continue; + } + delete outdatedDependencies[parentId]; + outdatedModules.push(parentId); + queue.push({ + chain: chain.concat([parentId]), + id: parentId + }); + } + } + + return { + type: "accepted", + moduleId: updateModuleId, + outdatedModules: outdatedModules, + outdatedDependencies: outdatedDependencies + }; + } + + function addAllToSet(a, b) { + for (var i = 0; i < b.length; i++) { + var item = b[i]; + if (a.indexOf(item) === -1) a.push(item); + } + } + + var outdatedDependencies = {}; + var outdatedModules = []; + var appliedUpdate = {}; + + var warnUnexpectedRequire = function warnUnexpectedRequire(module) { + console.warn( + "[HMR] unexpected require(" + module.id + ") to disposed module" + ); + throw Error("RuntimeError: factory is undefined(" + module.id + ")"); + }; + + for (var moduleId in hotCurrentUpdate) { + if (__webpack_require__.o(hotCurrentUpdate, moduleId)) { + var newModuleFactory = hotCurrentUpdate[moduleId]; + var result = newModuleFactory ? getAffectedModuleEffects(moduleId) : { + type: "disposed", + moduleId: moduleId + }; + var abortError = false; + var doApply = false; + var doDispose = false; + var chainInfo = ""; + if (result.chain) { + chainInfo = "\nUpdate propagation: " + result.chain.join(" -> "); + } + switch (result.type) { + case "self-declined": + if (options.onDeclined) options.onDeclined(result); + if (!options.ignoreDeclined) + abortError = new Error( + "Aborted because of self decline: " + result.moduleId + chainInfo + ); + break; + case "declined": + if (options.onDeclined) options.onDeclined(result); + if (!options.ignoreDeclined) + abortError = new Error( + "Aborted because of declined dependency: " + + result.moduleId + + " in " + + result.parentId + + chainInfo + ); + break; + case "unaccepted": + if (options.onUnaccepted) options.onUnaccepted(result); + if (!options.ignoreUnaccepted) + abortError = new Error( + "Aborted because " + moduleId + " is not accepted" + chainInfo + ); + break; + case "accepted": + if (options.onAccepted) options.onAccepted(result); + doApply = true; + break; + case "disposed": + if (options.onDisposed) options.onDisposed(result); + doDispose = true; + break; + default: + throw new Error("Unexception type " + result.type); + } + if (abortError) { + return { + error: abortError + }; + } + if (doApply) { + appliedUpdate[moduleId] = newModuleFactory; + addAllToSet(outdatedModules, result.outdatedModules); + for (moduleId in result.outdatedDependencies) { + if (__webpack_require__.o(result.outdatedDependencies, moduleId)) { + if (!outdatedDependencies[moduleId]) + outdatedDependencies[moduleId] = []; + addAllToSet( + outdatedDependencies[moduleId], + result.outdatedDependencies[moduleId] + ); + } + } + } + if (doDispose) { + addAllToSet(outdatedModules, [result.moduleId]); + appliedUpdate[moduleId] = warnUnexpectedRequire; + } + } + } + hotCurrentUpdate = undefined; + + var outdatedSelfAcceptedModules = []; + for (var j = 0; j < outdatedModules.length; j++) { + var outdatedModuleId = outdatedModules[j]; + var module = __webpack_require__.c[outdatedModuleId]; + if ( + module && + (module.hot._selfAccepted || module.hot._main) && + // removed self-accepted modules should not be required + appliedUpdate[outdatedModuleId] !== warnUnexpectedRequire && + // when called invalidate self-accepting is not possible + !module.hot._selfInvalidated + ) { + outdatedSelfAcceptedModules.push({ + module: outdatedModuleId, + require: module.hot._requireSelf, + errorHandler: module.hot._selfAccepted + }); + } + } + + if (self.__HMR_UPDATED_RUNTIME__) { + self.__HMR_UPDATED_RUNTIME__.javascript.outdatedModules = outdatedModules; + self.__HMR_UPDATED_RUNTIME__.javascript.outdatedDependencies = outdatedDependencies; + } + + + var moduleOutdatedDependencies; + return { + dispose: function () { + hotCurrentUpdateRemovedChunks.forEach(function (chunkId) { + delete jsonpInstalledChunks[chunkId]; + }); + hotCurrentUpdateRemovedChunks = undefined; + + var idx; + var queue = outdatedModules.slice(); + while (queue.length > 0) { + var moduleId = queue.pop(); + var module = __webpack_require__.c[moduleId]; + if (!module) continue; + + var data = {}; + + // Call dispose handlers + var disposeHandlers = module.hot._disposeHandlers; + + if (disposeHandlers.length > 0 && self.__HMR_UPDATED_RUNTIME__) { + self.__HMR_UPDATED_RUNTIME__.javascript.disposedModules.push(moduleId); + } + + for (j = 0; j < disposeHandlers.length; j++) { + disposeHandlers[j].call(null, data); + } + __webpack_require__.hmrD[moduleId] = data; + + module.hot.active = false; + + delete __webpack_require__.c[moduleId]; + + delete outdatedDependencies[moduleId]; + + for (j = 0; j < module.children.length; j++) { + var child = __webpack_require__.c[module.children[j]]; + if (!child) continue; + idx = child.parents.indexOf(moduleId); + if (idx >= 0) { + child.parents.splice(idx, 1); + } + } + } + + var dependency; + for (var outdatedModuleId in outdatedDependencies) { + if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) { + module = __webpack_require__.c[outdatedModuleId]; + if (module) { + moduleOutdatedDependencies = outdatedDependencies[outdatedModuleId]; + for (j = 0; j < moduleOutdatedDependencies.length; j++) { + dependency = moduleOutdatedDependencies[j]; + idx = module.children.indexOf(dependency); + if (idx >= 0) module.children.splice(idx, 1); + } + } + } + } + }, + apply: function (reportError) { + // insert new code + for (var updateModuleId in appliedUpdate) { + if (__webpack_require__.o(appliedUpdate, updateModuleId)) { + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } + + if (self.__HMR_UPDATED_RUNTIME__) { + self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); + } + + } + } + + // run new runtime modules + for (var i = 0; i < hotCurrentUpdateRuntime.length; i++) { + + hotCurrentUpdateRuntime[i](new Proxy(__webpack_require__, { + set(target, prop, value, receiver) { + if (self.__HMR_UPDATED_RUNTIME__) { + self.__HMR_UPDATED_RUNTIME__.javascript.updatedRuntime.push(`__webpack_require__.${prop}`); + } + return Reflect.set(target, prop, value, receiver); + } + })); + + } + + // call accept handlers + for (var outdatedModuleId in outdatedDependencies) { + if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) { + var module = __webpack_require__.c[outdatedModuleId]; + if (module) { + moduleOutdatedDependencies = outdatedDependencies[outdatedModuleId]; + var callbacks = []; + var errorHandlers = []; + var dependenciesForCallbacks = []; + for (var j = 0; j < moduleOutdatedDependencies.length; j++) { + var dependency = moduleOutdatedDependencies[j]; + var acceptCallback = module.hot._acceptedDependencies[dependency]; + var errorHandler = module.hot._acceptedErrorHandlers[dependency]; + if (acceptCallback) { + if (callbacks.indexOf(acceptCallback) !== -1) continue; + callbacks.push(acceptCallback); + errorHandlers.push(errorHandler); + + if (self.__HMR_UPDATED_RUNTIME__) { + self.__HMR_UPDATED_RUNTIME__.javascript.acceptedModules.push(dependency); + } + + dependenciesForCallbacks.push(dependency); + } + } + for (var k = 0; k < callbacks.length; k++) { + try { + callbacks[k].call(null, moduleOutdatedDependencies); + } catch (err) { + if (typeof errorHandlers[k] === "function") { + try { + errorHandlers[k](err, { + moduleId: outdatedModuleId, + dependencyId: dependenciesForCallbacks[k] + }); + } catch (err2) { + if (options.onErrored) { + options.onErrored({ + type: "accept-error-handler-errored", + moduleId: outdatedModuleId, + dependencyId: dependenciesForCallbacks[k], + error: err2, + originalError: err + }); + } + if (!options.ignoreErrored) { + reportError(err2); + reportError(err); + } + } + } else { + if (options.onErrored) { + options.onErrored({ + type: "accept-errored", + moduleId: outdatedModuleId, + dependencyId: dependenciesForCallbacks[k], + error: err + }); + } + if (!options.ignoreErrored) { + reportError(err); + } + } + } + } + } + } + } + + // Load self accepted modules + for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) { + var item = outdatedSelfAcceptedModules[o]; + var moduleId = item.module; + try { + item.require(moduleId); + } catch (err) { + if (typeof item.errorHandler === "function") { + try { + item.errorHandler(err, { + moduleId: moduleId, + module: __webpack_require__.c[moduleId] + }); + } catch (err1) { + if (options.onErrored) { + options.onErrored({ + type: "self-accept-error-handler-errored", + moduleId: moduleId, + error: err1, + originalError: err + }); + } + if (!options.ignoreErrored) { + reportError(err1); + reportError(err); + } + } + } else { + if (options.onErrored) { + options.onErrored({ + type: "self-accept-errored", + moduleId: moduleId, + error: err + }); + } + if (!options.ignoreErrored) { + reportError(err); + } + } + } + } + + return outdatedModules; + } + }; +} + +__webpack_require__.hmrI.jsonp = function (moduleId, applyHandlers) { + if (!hotCurrentUpdate) { + hotCurrentUpdate = {}; + hotCurrentUpdateRuntime = []; + hotCurrentUpdateRemovedChunks = []; + applyHandlers.push(hotApplyHandler); + } + if (!__webpack_require__.o(hotCurrentUpdate, moduleId)) { + hotCurrentUpdate[moduleId] = __webpack_require__.m[moduleId]; + } +}; + +__webpack_require__.hmrC.jsonp = function ( + chunkIds, + removedChunks, + removedModules, + promises, + applyHandlers, + updatedModulesList +) { + applyHandlers.push(hotApplyHandler); + hotCurrentUpdateChunks = {}; + hotCurrentUpdateRemovedChunks = removedChunks; + hotCurrentUpdate = removedModules.reduce(function (obj, key) { + obj[key] = false; + return obj; + }, {}); + hotCurrentUpdateRuntime = []; + chunkIds.forEach(function (chunkId) { + if ( + __webpack_require__.o(jsonpInstalledChunks, chunkId) && + jsonpInstalledChunks[chunkId] !== undefined + ) { + promises.push(jsonpLoadUpdateChunk(chunkId, updatedModulesList)); + hotCurrentUpdateChunks[chunkId] = true; + } else { + hotCurrentUpdateChunks[chunkId] = false; + } + }); + if (__webpack_require__.f) { + __webpack_require__.f.jsonpHmr = function (chunkId, promises) { + if ( + hotCurrentUpdateChunks && + __webpack_require__.o(hotCurrentUpdateChunks, chunkId) && + !hotCurrentUpdateChunks[chunkId] + ) { + promises.push(jsonpLoadUpdateChunk(chunkId)); + hotCurrentUpdateChunks[chunkId] = true; + } + }; + } +}; +__webpack_require__.hmrM = () => { + if (typeof fetch === "undefined") + throw new Error("No browser support: need fetch API"); + return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then( + (response) => { + if (response.status === 404) return; // no update available + if (!response.ok) + throw new Error( + "Failed to fetch update manifest " + response.statusText + ); + return response.json(); + } + ); +}; +__webpack_require__.O.j = (chunkId) => (jsonpInstalledChunks[chunkId] === 0); +// install a JSONP callback for chunk loading +var __rspack_jsonp = (parentChunkLoadingFunction, data) => { + var [chunkIds, moreModules, runtime] = data; + // add "moreModules" to the modules object, + // then flag all "chunkIds" as loaded and fire callback + var moduleId, chunkId, i = 0; + if (chunkIds.some((id) => (jsonpInstalledChunks[id] !== 0))) { + for (moduleId in moreModules) { + if (__webpack_require__.o(moreModules, moduleId)) { + __webpack_require__.m[moduleId] = moreModules[moduleId]; + } + } + if (runtime) var result = runtime(__webpack_require__); + } + if (parentChunkLoadingFunction) parentChunkLoadingFunction(data); + for (; i < chunkIds.length; i++) { + chunkId = chunkIds[i]; + if ( + __webpack_require__.o(jsonpInstalledChunks, chunkId) && + jsonpInstalledChunks[chunkId] + ) { + jsonpInstalledChunks[chunkId][0](); + } + jsonpInstalledChunks[chunkId] = 0; + } + + return __webpack_require__.O(result); + +}; + +var jsonpChunkLoadingGlobal = self["rspackChunk"] = self["rspackChunk"] || []; +jsonpChunkLoadingGlobal.forEach(__rspack_jsonp.bind(null, 0)); +jsonpChunkLoadingGlobal.push = __rspack_jsonp.bind(null, jsonpChunkLoadingGlobal.push.bind(jsonpChunkLoadingGlobal)); + +})(); + +} +); +``` \ No newline at end of file diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/index.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/index.js new file mode 100644 index 000000000000..2d212af70d66 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/index.js @@ -0,0 +1,13 @@ +import "lib-js/a"; +import.meta.webpackHot.accept(); + +it("should work if there are new initial chunks", async () => { + expect((await import("./initial")).value).toBe("a"); + await NEXT_HMR(); +}); +--- +import "lib-js/a"; + +it("should work if there are new initial chunks", async () => { + expect((await import("./initial")).value).toBe("b"); +}); diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/initial.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/initial.js new file mode 100644 index 000000000000..af4c2dbebc28 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/initial.js @@ -0,0 +1,5 @@ +require("lib-js/a"); +export const value = "a"; +--- +require("lib-js/b"); +export const value = "b"; diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/a.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/a.js new file mode 100644 index 000000000000..9233cce2f0e1 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/a.js @@ -0,0 +1 @@ +export const a = "a"; diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/b.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/b.js new file mode 100644 index 000000000000..ba63f81d751d --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/node_modules/lib-js/b.js @@ -0,0 +1 @@ +export const b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/rspack.config.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/rspack.config.js new file mode 100644 index 000000000000..5c1fb72db580 --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/rspack.config.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + optimization: { + chunkIds: 'named', + moduleIds: 'named', + minimize: false, + concatenateModules: false, + splitChunks: { + minSize: 1000, + chunks: 'all', + cacheGroups: { + lib: { + test: /lib-js/, + name: 'lib', + }, + default: false, + defaultVendors: false, + }, + }, + mangleExports: false, + }, + output: { + filename: '[name].[fullhash].js', + chunkFilename: 'async/[name].js', + }, +}; diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/test.filter.js b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/test.filter.js new file mode 100644 index 000000000000..dd00aa17d21b --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/test.filter.js @@ -0,0 +1,2 @@ +module.exports = ({ rspackOptions, target }) => + target === 'web' && rspackOptions?.experiments?.runtimeMode !== 'rspack'; diff --git a/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt index b7591de4a0ec..deb7fc6a9094 100644 --- a/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt @@ -12,7 +12,7 @@ - Bundle: vendors-node_modules_vendor_js.js - Manifest: runtime~main.LAST_HASH.hot-update.json, size: 76 - Update: main.LAST_HASH.hot-update.js, size: 660 -- Update: runtime~main.LAST_HASH.hot-update.js, size: 19242 +- Update: runtime~main.LAST_HASH.hot-update.js, size: 20884 - Update: vendors-node_modules_vendor_js.LAST_HASH.hot-update.js, size: 374 ## Manifest @@ -93,9 +93,9 @@ __webpack_require__.e = (chunkId) => { // This function allow to reference chunks __webpack_require__.u = (chunkId) => { // return url for filenames not based on template - + if (chunkId === "chunk_js") return "" + chunkId + ".chunk." + __webpack_require__.h() + ".js"; // return url for filenames based on template - return "" + chunkId + ".chunk." + __webpack_require__.h() + ".js" + return "" + chunkId + ".js" } })(); // webpack/runtime/get_full_hash @@ -211,7 +211,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -478,7 +478,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (__webpack_require__.o(appliedUpdate, updateModuleId)) { - __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/tests/rspack-test/hotCases/css/initial-css-fullhash/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/css/initial-css-fullhash/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..7f22d42862cd --- /dev/null +++ b/tests/rspack-test/hotCases/css/initial-css-fullhash/__snapshots__/web/0.snap.txt @@ -0,0 +1,12 @@ +# Case initial-css-fullhash: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: bundle.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/css/initial-css-fullhash/index.css b/tests/rspack-test/hotCases/css/initial-css-fullhash/index.css new file mode 100644 index 000000000000..60f1eab97137 --- /dev/null +++ b/tests/rspack-test/hotCases/css/initial-css-fullhash/index.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/tests/rspack-test/hotCases/css/initial-css-fullhash/index.js b/tests/rspack-test/hotCases/css/initial-css-fullhash/index.js new file mode 100644 index 000000000000..a34aadf3466e --- /dev/null +++ b/tests/rspack-test/hotCases/css/initial-css-fullhash/index.js @@ -0,0 +1,9 @@ +import './index.css'; + +it("includes the full hash runtime for initial CSS filenames", () => { + if (__webpack_require__.hmrC.css) { + expect(typeof __webpack_require__.h).toBe("function"); + } +}); + +module.hot.accept("./index.css"); diff --git a/tests/rspack-test/hotCases/css/initial-css-fullhash/rspack.config.js b/tests/rspack-test/hotCases/css/initial-css-fullhash/rspack.config.js new file mode 100644 index 000000000000..c36375ec0468 --- /dev/null +++ b/tests/rspack-test/hotCases/css/initial-css-fullhash/rspack.config.js @@ -0,0 +1,15 @@ +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + output: { + cssFilename: 'bundle.[fullhash].css', + cssChunkFilename: '[name].css', + }, + module: { + rules: [ + { + test: /\.css/, + type: 'css/auto', + }, + ], + }, +}; diff --git a/tests/rspack-test/hotCases/css/initial-css-fullhash/test.config.js b/tests/rspack-test/hotCases/css/initial-css-fullhash/test.config.js new file mode 100644 index 000000000000..114482267044 --- /dev/null +++ b/tests/rspack-test/hotCases/css/initial-css-fullhash/test.config.js @@ -0,0 +1,3 @@ +module.exports = { + documentType: "jsdom" +}; diff --git a/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt index 7c2da17616c3..47e165ce5105 100644 --- a/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt @@ -8,7 +8,7 @@ - Bundle: chunk1_js.chunk.CURRENT_HASH.js - Manifest: [runtime of chunk2_js].LAST_HASH.hot-update.json, size: 60 - Manifest: main.LAST_HASH.hot-update.json, size: 41 -- Update: main.LAST_HASH.hot-update.js, size: 18632 +- Update: main.LAST_HASH.hot-update.js, size: 20218 ## Manifest @@ -167,7 +167,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -434,7 +434,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (__webpack_require__.o(appliedUpdate, updateModuleId)) { - __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt index 592506a97dc6..ed819d0f94f7 100644 --- a/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: chunk1_js.chunk.CURRENT_HASH.js -- Update: main.LAST_HASH.hot-update.js, size: 18632 +- Update: main.LAST_HASH.hot-update.js, size: 20218 ## Manifest @@ -151,7 +151,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -418,7 +418,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (__webpack_require__.o(appliedUpdate, updateModuleId)) { - __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt index 19e9265fc233..6ac8b5178c7e 100644 --- a/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Manifest: main.LAST_HASH.hot-update.json, size: 42 -- Update: main.LAST_HASH.hot-update.js, size: 15545 +- Update: main.LAST_HASH.hot-update.js, size: 17131 ## Manifest @@ -101,7 +101,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -368,7 +368,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (__webpack_require__.o(appliedUpdate, updateModuleId)) { - __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..85f0915bc5e4 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/0.snap.txt @@ -0,0 +1,12 @@ +# Case preserve-disposed-module-factories-error: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: bundle.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/1.snap.txt new file mode 100644 index 000000000000..824bad211f72 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/__snapshots__/web/1.snap.txt @@ -0,0 +1,59 @@ +# Case preserve-disposed-module-factories-error: Step 1 + +## Changed Files +- module.js + +## Asset Files +- Bundle: bundle.js +- Manifest: main.LAST_HASH.hot-update.json, size: 36 +- Update: main.LAST_HASH.hot-update.js, size: 622 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["main"],"r":[],"m":["./a.js"]} +``` + + +## Update + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./b.js +- ./module.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash + +#### Changed Content +```js +self["rspackHotUpdate"]("main", { +"./b.js"(__unused_rspack_module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +__webpack_require__.d(__webpack_exports__, { + "default": () => (__rspack_default_export) +}); +/* export default */ const __rspack_default_export = ("b"); + + +}, +"./module.js"(module, __unused_rspack_exports, __webpack_require__) { +module.exports = () => (__webpack_require__("./b.js")/* ["default"] */["default"]); + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); + +} +); +``` \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/a.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/a.js new file mode 100644 index 000000000000..e94fef18587e --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/a.js @@ -0,0 +1 @@ +export default "a"; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/b.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/b.js new file mode 100644 index 000000000000..eff703ff4657 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/b.js @@ -0,0 +1 @@ +export default "b"; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/index.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/index.js new file mode 100644 index 000000000000..fdbfed6afa74 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/index.js @@ -0,0 +1,21 @@ +const initialRemovedFactory = __webpack_require__.m["./a.js"]; +const readRemovedModule = require("./module"); + +module.hot.accept("./module", () => { + throw new Error("accept failed"); +}); + +it("should finalize disposed factories when apply rejects", async () => { + await expect( + NEXT_HMR({ preserveDisposedModuleFactories: true }) + ).rejects.toThrow("accept failed"); + + expect(__webpack_require__.m["./a.js"]).not.toBe(initialRemovedFactory); + const warn = console.warn; + console.warn = () => {}; + try { + expect(readRemovedModule).toThrow("RuntimeError: factory is undefined(./a.js)"); + } finally { + console.warn = warn; + } +}); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/module.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/module.js new file mode 100644 index 000000000000..1a3606c7a849 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-error/module.js @@ -0,0 +1,3 @@ +module.exports = () => require("./a").default; +--- +module.exports = () => require("./b").default; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..df7381743d0c --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/0.snap.txt @@ -0,0 +1,12 @@ +# Case preserve-disposed-module-factories-queued: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: bundle.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/1.snap.txt new file mode 100644 index 000000000000..0763455576b7 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/__snapshots__/web/1.snap.txt @@ -0,0 +1,48 @@ +# Case preserve-disposed-module-factories-queued: Step 1 + +## Changed Files +- module.js + +## Asset Files +- Bundle: bundle.js +- Manifest: main.LAST_HASH.hot-update.json, size: 36 +- Update: main.LAST_HASH.hot-update.js, size: 232 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["main"],"r":[],"m":["./a.js"]} +``` + + +## Update + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./module.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash + +#### Changed Content +```js +self["rspackHotUpdate"]("main", { +"./module.js"(module) { +module.exports = () => "current"; + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); + +} +); +``` \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/a.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/a.js new file mode 100644 index 000000000000..b1dfa7fa28d9 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/a.js @@ -0,0 +1,4 @@ +export const value = "a"; +export const invalidate = () => module.hot.invalidate(); + +module.hot.accept(); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/index.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/index.js new file mode 100644 index 000000000000..bf84024cc34f --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/index.js @@ -0,0 +1,19 @@ +const initialRemovedFactory = __webpack_require__.m["./a.js"]; +const readRemovedModule = require("./module"); +const removedModule = readRemovedModule(); +let valueDuringApply; + +module.hot.accept("./module", () => { + valueDuringApply = removedModule.value; + removedModule.invalidate(); +}); + +it("should finalize after recursively applying queued invalidations", async () => { + await NEXT_HMR({ + ignoreUnaccepted: true, + preserveDisposedModuleFactories: true + }); + + expect(valueDuringApply).toBe("a"); + expect(__webpack_require__.m["./a.js"]).toBe(initialRemovedFactory); +}); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/module.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/module.js new file mode 100644 index 000000000000..61d51d4dd124 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories-queued/module.js @@ -0,0 +1,3 @@ +module.exports = () => require("./a"); +--- +module.exports = () => "current"; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/0.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/0.snap.txt new file mode 100644 index 000000000000..7eb6bef2ab6b --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/0.snap.txt @@ -0,0 +1,12 @@ +# Case preserve-disposed-module-factories: Step 0 + +## Changed Files + + +## Asset Files +- Bundle: bundle.js + +## Manifest + + +## Update \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/1.snap.txt new file mode 100644 index 000000000000..57efe56fc23e --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/1.snap.txt @@ -0,0 +1,59 @@ +# Case preserve-disposed-module-factories: Step 1 + +## Changed Files +- module.js + +## Asset Files +- Bundle: bundle.js +- Manifest: main.LAST_HASH.hot-update.json, size: 36 +- Update: main.LAST_HASH.hot-update.js, size: 622 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["main"],"r":[],"m":["./a.js"]} +``` + + +## Update + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./b.js +- ./module.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash + +#### Changed Content +```js +self["rspackHotUpdate"]("main", { +"./b.js"(__unused_rspack_module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +__webpack_require__.d(__webpack_exports__, { + "default": () => (__rspack_default_export) +}); +/* export default */ const __rspack_default_export = ("b"); + + +}, +"./module.js"(module, __unused_rspack_exports, __webpack_require__) { +module.exports = () => (__webpack_require__("./b.js")/* ["default"] */["default"]); + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); + +} +); +``` \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/2.snap.txt new file mode 100644 index 000000000000..6dd9ad82d504 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/__snapshots__/web/2.snap.txt @@ -0,0 +1,97 @@ +# Case preserve-disposed-module-factories: Step 2 + +## Changed Files +- module.js + +## Asset Files +- Bundle: bundle.js +- Manifest: main.LAST_HASH.hot-update.json, size: 36 +- Update: main.LAST_HASH.hot-update.js, size: 232 + +## Manifest + +### main.LAST_HASH.hot-update.json + +```json +{"c":["main"],"r":[],"m":["./b.js"]} +``` + + +## Update + + +### main.LAST_HASH.hot-update.js + +#### Changed Modules +- ./module.js + +#### Changed Runtime Modules +- webpack/runtime/get_full_hash + +#### Changed Content +```js +self["rspackHotUpdate"]("main", { +"./module.js"(module) { +module.exports = () => "current"; + + +}, + +},function(__webpack_require__) { +// webpack/runtime/get_full_hash +(() => { +__webpack_require__.h = () => ("CURRENT_HASH") +})(); + +} +); +``` + + + + +## Runtime +### Status + +```txt +check => prepare => dispose => apply => idle +``` + + + +### JavaScript + +#### Outdated + +Outdated Modules: +- ./a.js +- ./b.js +- ./module.js + + +Outdated Dependencies: +```json +{ + "./index.js": [ + "./module.js" + ] +} +``` + +#### Updated + +Updated Modules: +- ./a.js +- ./b.js +- ./module.js + +Updated Runtime: +- `__webpack_require__.h` + + +#### Callback + +Accepted Callback: +- ./module.js + +Disposed Callback: \ No newline at end of file diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js new file mode 100644 index 000000000000..a1219a851ba1 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js @@ -0,0 +1,4 @@ +import { getParents } from "./child"; + +getParents(); +export default "a"; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/b.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/b.js new file mode 100644 index 000000000000..eff703ff4657 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/b.js @@ -0,0 +1 @@ +export default "b"; diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/child.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/child.js new file mode 100644 index 000000000000..d1586d86bc8c --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/child.js @@ -0,0 +1 @@ +export const getParents = () => module.parents.slice(); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js new file mode 100644 index 000000000000..d682c17386f1 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js @@ -0,0 +1,49 @@ +const initialRemovedFactory = __webpack_require__.m["./a.js"]; +const readRemovedModule = require("./module"); +const getChildParents = require("./child").getParents; +let removedModuleId = "./a.js"; +let factoryDuringApply; +let factoryAtIdle; +let valueDuringApply; +let childParentsDuringApply; + +module.hot.accept("./module", () => { + factoryDuringApply = __webpack_require__.m[removedModuleId]; + if (removedModuleId === "./a.js") { + valueDuringApply = readRemovedModule(); + childParentsDuringApply = getChildParents(); + } +}); + +const checkDisposedFactoryAtIdle = (status) => { + if (status !== "idle") return; + factoryAtIdle = __webpack_require__.m[removedModuleId]; +}; +module.hot.addStatusHandler(checkDisposedFactoryAtIdle); + +it("should preserve disposed factories only for the requested apply transaction", async () => { + await NEXT_HMR({ preserveDisposedModuleFactories: true }); + + expect(factoryDuringApply).toBe(initialRemovedFactory); + expect(valueDuringApply).toBe("a"); + expect(childParentsDuringApply).toContain("./a.js"); + expect(getChildParents()).not.toContain("./a.js"); + expect(factoryAtIdle).not.toBe(initialRemovedFactory); + expect(__webpack_require__.m["./a.js"]).toBe(factoryAtIdle); + const warn = console.warn; + console.warn = () => {}; + try { + expect(readRemovedModule).toThrow("RuntimeError: factory is undefined(./a.js)"); + } finally { + console.warn = warn; + } + + module.hot.removeStatusHandler(checkDisposedFactoryAtIdle); + require("./module"); + removedModuleId = "./b.js"; + const secondRemovedFactory = __webpack_require__.m[removedModuleId]; + await NEXT_HMR(); + + expect(factoryDuringApply).not.toBe(secondRemovedFactory); + expect(__webpack_require__.m[removedModuleId]).toBe(factoryDuringApply); +}); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/module.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/module.js new file mode 100644 index 000000000000..8e419ae1478e --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/module.js @@ -0,0 +1,5 @@ +module.exports = () => require("./a").default; +--- +module.exports = () => require("./b").default; +--- +module.exports = () => "current"; diff --git a/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt index a0be64cf707c..1bf97ab0ef9d 100644 --- a/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ - Bundle: bundle.js - Bundle: common_js_2.chunk.CURRENT_HASH.js - Manifest: main.LAST_HASH.hot-update.json, size: 28 -- Update: main.LAST_HASH.hot-update.js, size: 42679 +- Update: main.LAST_HASH.hot-update.js, size: 44265 ## Manifest @@ -777,7 +777,7 @@ var hotCurrentUpdateChunks; var hotCurrentUpdate; var hotCurrentUpdateRemovedChunks; var hotCurrentUpdateRuntime; -function hotApplyHandler(options) { +function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr; hotCurrentUpdateChunks = undefined; function getAffectedModuleEffects(updateModuleId) { @@ -1044,7 +1044,45 @@ function hotApplyHandler(options) { // insert new code for (var updateModuleId in appliedUpdate) { if (__webpack_require__.o(appliedUpdate, updateModuleId)) { - __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; + var newModuleFactory = appliedUpdate[updateModuleId]; + var preservedModuleFactories = + moduleFactoryTransaction && + moduleFactoryTransaction.javascriptModuleFactories; + var hasPreservedModuleFactory = + preservedModuleFactories && + __webpack_require__.o(preservedModuleFactories, updateModuleId); + // Keep removed factories usable until the outer apply transaction settles. + if ( + moduleFactoryTransaction && + (newModuleFactory === warnUnexpectedRequire || hasPreservedModuleFactory) + ) { + if (!preservedModuleFactories) { + preservedModuleFactories = Object.create(null); + moduleFactoryTransaction.javascriptModuleFactories = + preservedModuleFactories; + moduleFactoryTransaction.finalizers.push(function () { + for (var moduleId in preservedModuleFactories) { + if (__webpack_require__.o(preservedModuleFactories, moduleId)) { + var module = __webpack_require__.c[moduleId]; + if (module) { + for (var i = 0; i < module.children.length; i++) { + var child = __webpack_require__.c[module.children[i]]; + if (!child) continue; + var idx = child.parents.indexOf(moduleId); + if (idx >= 0) child.parents.splice(idx, 1); + } + } + delete __webpack_require__.c[moduleId]; + __webpack_require__.m[moduleId] = + preservedModuleFactories[moduleId]; + } + } + }); + } + preservedModuleFactories[updateModuleId] = newModuleFactory; + } else { + __webpack_require__.m[updateModuleId] = newModuleFactory; + } if (self.__HMR_UPDATED_RUNTIME__) { self.__HMR_UPDATED_RUNTIME__.javascript.updatedModules.push(updateModuleId); diff --git a/tests/rspack-test/multiCompilerCases/invalidate.js b/tests/rspack-test/multiCompilerCases/invalidate.js index fa6cdffea81f..3af3b0fb30c8 100644 --- a/tests/rspack-test/multiCompilerCases/invalidate.js +++ b/tests/rspack-test/multiCompilerCases/invalidate.js @@ -5,6 +5,7 @@ const path = require("path"); module.exports = [ (() => { const events = []; + const lazyCompilationCycles = []; let state = 0; return { description: "should respect dependencies when using invalidate", @@ -36,6 +37,11 @@ module.exports = [ c.hooks.done.tap("test", () => { events.push(`${c.name} done`); }); + c.hooks.thisCompilation.tap("test", compilation => { + if (c.name === "a") { + lazyCompilationCycles.push(compilation.watchInvalidationKind); + } + }); }); compiler.watchFileSystem = { watch() { } }; @@ -48,10 +54,9 @@ module.exports = [ reject(error); return; } - if (state !== 0) return; - state++; - - expect(events).toMatchInlineSnapshot(` + if (state === 0) { + state = 1; + expect(events).toMatchInlineSnapshot(` Array [ b run, b done, @@ -59,13 +64,13 @@ module.exports = [ a done, ] `); - events.length = 0; + events.length = 0; - watching.invalidate(err => { - try { - if (err) return reject(err); + watching.__internal__invalidate("lazy", err => { + try { + if (err) return reject(err); - expect(events).toMatchInlineSnapshot(` + expect(events).toMatchInlineSnapshot(` Array [ a invalid, b invalid, @@ -75,16 +80,44 @@ module.exports = [ a done, ] `); - events.length = 0; - expect(state).toBe(1); - setTimeout(() => { - compiler.close(resolve); - }, 1000); - } catch (e) { - console.error(e); - reject(e); - } - }); + expect(lazyCompilationCycles).toEqual([undefined, "lazy"]); + events.length = 0; + expect(state).toBe(1); + state = 2; + watching.watchings[0].__internal__invalidate("lazy"); + watching.watchings[1].invalidate(error => { + if (error) reject(error); + }); + } catch (e) { + console.error(e); + reject(e); + } + }); + return; + } + + if (state !== 2) return; + try { + expect(events).toMatchInlineSnapshot(` + Array [ + a invalid, + b invalid, + b run, + b done, + a run, + a done, + ] + `); + expect(lazyCompilationCycles).toEqual([ + undefined, + "lazy", + "normal", + ]); + state = 3; + compiler.close(error => (error ? reject(error) : resolve())); + } catch (error) { + compiler.close(() => reject(error)); + } }); }); } diff --git a/website/docs/en/api/runtime-api/hmr.mdx b/website/docs/en/api/runtime-api/hmr.mdx index ef887242dc98..a1d4c76e612a 100644 --- a/website/docs/en/api/runtime-api/hmr.mdx +++ b/website/docs/en/api/runtime-api/hmr.mdx @@ -311,6 +311,7 @@ The optional `options` object can include the following properties: - `ignoreUnaccepted` (boolean): Ignore changes made to unaccepted modules. - `ignoreDeclined` (boolean): Ignore changes made to declined modules. - `ignoreErrored` (boolean): Ignore errors thrown in accept handlers, error handlers and while reevaluating module. +- `preserveDisposedModuleFactories` (boolean): Only keep factories for modules removed from the compilation readable until the current apply transaction settles. Defaults to `false` and is intended for activating modules through lazy compilation. - `onDeclined` (function(info)): Notifier for declined modules - `onUnaccepted` (function(info)): Notifier for unaccepted modules - `onAccepted` (function(info)): Notifier for accepted modules diff --git a/website/docs/zh/api/runtime-api/hmr.mdx b/website/docs/zh/api/runtime-api/hmr.mdx index 1c27548491f1..faea93d4c4b0 100644 --- a/website/docs/zh/api/runtime-api/hmr.mdx +++ b/website/docs/zh/api/runtime-api/hmr.mdx @@ -311,6 +311,7 @@ import.meta.webpackHot - `ignoreUnaccepted`(boolean):忽略对不可接受的模块所做的更改。 - `ignoreDeclined`(boolean):忽略对已拒绝的模块所做的更改。 - `ignoreErrored`(boolean):忽略在接受处理程序、错误处理器以及重新评估模块时抛出的错误。 +- `preserveDisposedModuleFactories`(boolean):仅在当前 apply 事务结束前,保持从 compilation 中移除的模块工厂可读取。默认为 `false`,用于激活通过 lazy compilation 编译的模块。 - `onDeclined`(function(info)):拒绝模块的通知者。 - `onUnaccepted`(function(info)):不可接受的模块的通知程序。 - `onAccepted`(function(info)):可接受模块的通知者。 From 2ee3fd607e1386366221876e69a6fd3f0bee440a Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:00:22 +0000 Subject: [PATCH 12/29] fix(watch): keep dependent lazy generations and completed deltas correct Snapshot completed watch deltas before user callbacks can synchronously invalidate, and classify artifact-dependent MultiCompiler children as normal while preserving source-only lazy activation. Add focused artifact-chain, coalescing, and delta-delivery regressions. --- packages/rspack/src/MultiCompiler.ts | 10 +- packages/rspack/src/Watching.ts | 66 ++-- .../LazyCompilationArtifactChain.test.js | 332 ++++++++++++++++++ .../WatchingDependencyRegistration.test.js | 99 ++++++ 4 files changed, 468 insertions(+), 39 deletions(-) create mode 100644 tests/rspack-test/LazyCompilationArtifactChain.test.js create mode 100644 tests/rspack-test/WatchingDependencyRegistration.test.js diff --git a/packages/rspack/src/MultiCompiler.ts b/packages/rspack/src/MultiCompiler.ts index 0d0fc6b86c49..17ac3f4e0fc1 100644 --- a/packages/rspack/src/MultiCompiler.ts +++ b/packages/rspack/src/MultiCompiler.ts @@ -396,12 +396,10 @@ export class MultiCompiler { node.state = 'done'; const invalidationKind = stats.compilation.watchInvalidationKind; for (const child of node.children) { - if ( - invalidationKind === 'normal' || - (invalidationKind === 'lazy' && - child.parentInvalidationKind === undefined) - ) { - child.parentInvalidationKind = invalidationKind; + // Dependents consume a newly emitted parent artifact and must take + // the normal path even when the parent was explicitly lazy. + if (invalidationKind !== undefined) { + child.parentInvalidationKind = 'normal'; } if (child.state === 'blocked') queue.enqueue(child); } diff --git a/packages/rspack/src/Watching.ts b/packages/rspack/src/Watching.ts index 53d799d73c69..c931db3a41e2 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -471,43 +471,43 @@ export class Watching { this.compiler.hooks.done.callAsync(stats, (err) => { if (err) return handleError(err, cbs); + + // Snapshot this build's watch deltas before user callbacks can invalidate. + this.#accumulateWatchDeps(compilation); + const pending = this.#pendingWatchDeps!; + this.#pendingWatchDeps = undefined; + + const fileDependencies = new Set([ + ...compilation.fileDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + fileDependencies.added = pending.file.added; + fileDependencies.removed = pending.file.removed; + + const contextDependencies = new Set([ + ...compilation.contextDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + contextDependencies.added = pending.context.added; + contextDependencies.removed = pending.context.removed; + + const missingDependencies = new Set([ + ...compilation.missingDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + missingDependencies.added = pending.missing.added; + missingDependencies.removed = pending.missing.removed; + this.handler(null, stats); process.nextTick(() => { if (!this.#closed) { - // Deliver this build's deltas merged with any carried from skipped - // coalesced builds, then reset the accumulator. - this.#accumulateWatchDeps(compilation); - const pending = this.#pendingWatchDeps!; - this.#pendingWatchDeps = undefined; - - const fileDependencies = new Set([ - ...compilation.fileDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - fileDependencies.added = pending.file.added; - fileDependencies.removed = pending.file.removed; - - const contextDependencies = new Set([ - ...compilation.contextDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - contextDependencies.added = pending.context.added; - contextDependencies.removed = pending.context.removed; - - const missingDependencies = new Set([ - ...compilation.missingDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - missingDependencies.added = pending.missing.added; - missingDependencies.removed = pending.missing.removed; - this.watch( fileDependencies, contextDependencies, diff --git a/tests/rspack-test/LazyCompilationArtifactChain.test.js b/tests/rspack-test/LazyCompilationArtifactChain.test.js new file mode 100644 index 000000000000..2043126993c1 --- /dev/null +++ b/tests/rspack-test/LazyCompilationArtifactChain.test.js @@ -0,0 +1,332 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const rspack = require("@rspack/core"); + +const names = ["source", "dependent", "tail", "unrelated"]; +const parent = { + source: undefined, + dependent: "source", + tail: "dependent", + unrelated: undefined +}; + +async function withTimeout(promise, description) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${description}`)), + 15000 + ); + }) + ]); + } finally { + clearTimeout(timeout); + } +} + +describe("lazy MultiCompiler artifact provenance", () => { + it("keeps dependent and coalesced file-backed generations normal", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-lazy-chain-")); + const sourceFile = path.join(root, "source.js"); + const coalescedEntry = path.join(root, "coalesced-source.js"); + const stampFile = name => path.join(root, name, "stamp.txt"); + const generations = Object.fromEntries(names.map(name => [name, 0])); + const events = []; + let delayedSource; + let resolveFileInvalidation; + let server; + let watching; + + try { + await Promise.all( + names.map(name => + fs.writeFile( + path.join(root, `${name}.js`), + `export const ${name} = "${name}";\n` + ) + ) + ); + await fs.writeFile( + coalescedEntry, + 'export const coalesced = "coalesced";\n' + ); + + const config = name => ({ + context: root, + dependencies: parent[name] ? [parent[name]] : [], + devtool: false, + entry: + name === "source" + ? { main: "./source.js", coalesced: "./coalesced-source.js" } + : { main: `./${name}.js` }, + experiments: { nativeWatcher: true }, + lazyCompilation: { entries: true, imports: false }, + mode: "development", + name, + output: { + chunkFilename: "[name].js", + filename: "[name].js", + path: path.join(root, name) + }, + plugins: [ + { + apply(compiler) { + compiler.hooks.invalid.tap("lazy-artifact-chain", file => { + if (name === "source" && file === sourceFile) { + resolveFileInvalidation?.(); + } + }); + compiler.hooks.make.tapPromise( + "lazy-artifact-chain", + async () => { + if (name === "source" && delayedSource) { + delayedSource.started(); + await delayedSource.release; + } + } + ); + compiler.hooks.thisCompilation.tap( + "lazy-artifact-chain", + compilation => { + compilation.hooks.processAssets.tapPromise( + { + name: "lazy-artifact-chain", + stage: + compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS + }, + async () => { + generations[name] += 1; + const upstream = parent[name] + ? ( + await fs.readFile(stampFile(parent[name]), "utf8") + ).trim() + : ""; + const stamp = upstream + ? `${upstream}>${name}${generations[name]}` + : `${name}${generations[name]}`; + compilation.emitAsset( + "stamp.txt", + new rspack.sources.RawSource(`${stamp}\n`) + ); + } + ); + } + ); + compiler.hooks.done.tap("lazy-artifact-chain", stats => { + events.push({ + name, + kind: stats.compilation.watchInvalidationKind, + changed: [...(compiler.modifiedFiles ?? [])].map(file => + path.basename(file) + ) + }); + }); + } + } + ], + target: "web" + }); + + const compiler = rspack.rspack(names.map(config)); + const middleware = rspack.lazyCompilationMiddleware(compiler); + server = http.createServer((request, response) => + middleware(request, response, () => { + response.statusCode = 404; + response.end(); + }) + ); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + + const builds = []; + const waiters = []; + const nextBuild = () => + builds.length + ? Promise.resolve(builds.shift()) + : new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for a watch build")), + 15000 + ); + waiters.push(value => { + clearTimeout(timeout); + resolve(value); + }); + }); + watching = compiler.watch({ aggregateTimeout: 20 }, (error, stats) => { + const value = { + error: + error ?? + (stats?.hasErrors() + ? new Error(stats.toString({ all: false, errors: true })) + : undefined), + children: stats?.stats ?? [stats] + }; + (waiters.shift() ?? (result => builds.push(result)))(value); + }); + + const assertBuild = (build, expectedNames, expectedKind) => { + assert.equal(build.error, undefined); + const children = build.children.filter(Boolean); + assert.deepEqual( + children.map(child => child.compilation.name).sort(), + [...expectedNames].sort() + ); + assert.deepEqual( + children.map(child => child.compilation.watchInvalidationKind), + children.map(child => + typeof expectedKind === "function" + ? expectedKind(child.compilation.name) + : expectedKind + ) + ); + }; + const assertTail = async () => { + assert.equal( + (await fs.readFile(stampFile("tail"), "utf8")).trim(), + `source${generations.source}>dependent${generations.dependent}>tail${generations.tail}` + ); + }; + + assertBuild(await nextBuild(), names, undefined); + const baseline = { ...generations }; + await assertTail(); + const lazyIds = new Map(); + for (const name of names) { + const bundle = await fs.readFile( + path.join(root, name, "main.js"), + "utf8" + ); + const encoded = bundle.match(/var data = ("(?:[^"\\]|\\.)*")/)?.[1]; + assert.notEqual( + encoded, + undefined, + `Missing lazy module ID for ${name}` + ); + lazyIds.set(name, JSON.parse(encoded)); + } + const coalescedBundle = await fs.readFile( + path.join(root, "source", "coalesced.js"), + "utf8" + ); + const coalescedId = coalescedBundle.match( + /var data = ("(?:[^"\\]|\\.)*")/ + )?.[1]; + assert.notEqual( + coalescedId, + undefined, + "Missing coalesced lazy module ID" + ); + const activate = (name, moduleId = lazyIds.get(name)) => + new Promise((resolve, reject) => { + const request = http.request( + { + host: "127.0.0.1", + method: "POST", + path: `/_rspack/lazy/trigger__${names.indexOf(name)}`, + port + }, + response => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + } + ); + request.on("error", reject); + request.end(moduleId); + }); + + const beforeUnrelated = events.length; + assert.equal(await activate("unrelated"), 200); + assertBuild(await nextBuild(), ["unrelated"], "lazy"); + assert.deepEqual( + events.slice(beforeUnrelated).map(event => event.name), + ["unrelated"] + ); + assert.equal(generations.source, baseline.source); + assert.equal(generations.dependent, baseline.dependent); + assert.equal(generations.tail, baseline.tail); + + const beforeLazy = events.length; + assert.equal(await activate("source"), 200); + assertBuild(await nextBuild(), ["source", "dependent", "tail"], name => + name === "source" ? "lazy" : "normal" + ); + assert.deepEqual( + events.slice(beforeLazy).map(event => [event.name, event.kind]), + [ + ["source", "lazy"], + ["dependent", "normal"], + ["tail", "normal"] + ] + ); + await assertTail(); + + let signalSourceStarted; + let releaseSource; + const sourceStarted = new Promise(resolve => { + signalSourceStarted = resolve; + }); + const sourceRelease = new Promise(resolve => { + releaseSource = resolve; + }); + const fileInvalidated = new Promise(resolve => { + resolveFileInvalidation = resolve; + }); + delayedSource = { started: signalSourceStarted, release: sourceRelease }; + const beforeCoalesced = events.length; + const beforeGenerations = { ...generations }; + assert.equal(await activate("source", JSON.parse(coalescedId)), 200); + await withTimeout(sourceStarted, "the coalesced source build to start"); + await fs.writeFile( + sourceFile, + 'export const source = "source";\nexport const edited = "file-edit";\n' + ); + await withTimeout(fileInvalidated, "the coalesced source file event"); + releaseSource(); + assertBuild(await nextBuild(), ["source", "dependent", "tail"], "normal"); + const coalesced = events.slice(beforeCoalesced); + assert.equal( + coalesced.every(event => event.kind === "normal"), + true, + `normal must dominate a coalesced file edit: ${JSON.stringify(coalesced)}` + ); + assert.equal( + coalesced.some( + event => + event.name === "source" && event.changed.includes("source.js") + ), + true, + `expected a file-backed source generation: ${JSON.stringify(coalesced)}` + ); + const emittedJavaScript = await Promise.all( + (await fs.readdir(path.join(root, "source"))) + .filter(file => file.endsWith(".js")) + .map(file => fs.readFile(path.join(root, "source", file), "utf8")) + ); + assert.equal( + emittedJavaScript.some(source => source.includes("file-edit")), + true, + "the coalesced file edit must reach an emitted JavaScript asset" + ); + assert.ok(generations.source > beforeGenerations.source); + assert.equal(generations.dependent, beforeGenerations.dependent + 1); + assert.equal(generations.tail, beforeGenerations.tail + 1); + assert.equal(generations.unrelated, beforeGenerations.unrelated); + await assertTail(); + } finally { + if (watching) { + await new Promise((resolve, reject) => + watching.close(error => (error ? reject(error) : resolve())) + ); + } + if (server) await new Promise(resolve => server.close(resolve)); + await fs.rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/tests/rspack-test/WatchingDependencyRegistration.test.js b/tests/rspack-test/WatchingDependencyRegistration.test.js new file mode 100644 index 000000000000..5cf36c9d140b --- /dev/null +++ b/tests/rspack-test/WatchingDependencyRegistration.test.js @@ -0,0 +1,99 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { rspack } = require("@rspack/core"); + +describe("Watching dependency registration", () => { + it("snapshots completed dependency deltas before the watch handler", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rspack-watch-deps-")); + const entry = path.join(root, "entry.js"); + const trackedFile = path.join(root, "tracked.js"); + const trackedContext = path.join(root, "tracked-context"); + const trackedMissing = path.join(root, "missing.js"); + fs.writeFileSync(entry, "export default true;\n"); + + let registration; + let resolveRegistration; + const registered = new Promise(resolve => { + resolveRegistration = resolve; + }); + const compiler = rspack({ + context: root, + entry: "./entry.js", + mode: "development", + output: { filename: "bundle.js", path: path.join(root, "dist") }, + plugins: [ + { + apply(compiler) { + compiler.hooks.done.tap("watch-dependency-regression", stats => { + for (const [key, value] of [ + ["__internal__addedFileDependencies", trackedFile], + ["__internal__addedContextDependencies", trackedContext], + ["__internal__addedMissingDependencies", trackedMissing] + ]) { + Object.defineProperty(stats.compilation, key, { + configurable: true, + value: [value] + }); + } + }); + } + } + ] + }); + compiler.watchFileSystem = { + watch(files, contexts, missing) { + registration = { + file: [...(files.added ?? [])], + context: [...(contexts.added ?? [])], + missing: [...(missing.added ?? [])] + }; + resolveRegistration(); + return { + close() {}, + pause() {}, + getInfo() { + return { + changes: new Set(), + removals: new Set(), + fileTimeInfoEntries: new Map(), + contextTimeInfoEntries: new Map() + }; + } + }; + } + }; + + let watching; + try { + await new Promise((resolve, reject) => { + watching = compiler.watch({}, (error, stats) => { + if (error || stats.hasErrors()) { + reject( + error ?? new Error(stats.toString({ all: false, errors: true })) + ); + return; + } + for (const key of [ + "__internal__addedFileDependencies", + "__internal__addedContextDependencies", + "__internal__addedMissingDependencies" + ]) { + Object.defineProperty(stats.compilation, key, { + configurable: true, + value: [] + }); + } + resolve(); + }); + }); + await registered; + expect(registration.file).toContain(trackedFile); + expect(registration.context).toContain(trackedContext); + expect(registration.missing).toContain(trackedMissing); + } finally { + if (watching) await new Promise(resolve => watching.close(resolve)); + fs.rmSync(root, { force: true, recursive: true }); + } + }); +}); From cd5589074f75df5a6ad916959a926dd92a25b08d Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:00:31 +0000 Subject: [PATCH 13/29] fix(watcher): keep native aggregate delivery lossless across generations Allow the single in-flight aggregate to use a lossless TSFN queue, release teardown-only failed callbacks safely, and compare uint32 generations with wrap-aware serial arithmetic. Add pending-event restart and generation-wrap regressions. --- .../rspack_binding_api/src/native_watcher.rs | 15 ++-- crates/rspack_watcher/src/lib.rs | 4 +- crates/rspack_watcher/tests/pending_events.rs | 71 +++++++++++++++++ packages/rspack/src/NativeWatchFileSystem.ts | 9 ++- .../NativeWatcherGeneration.test.js | 79 +++++++++++++------ 5 files changed, 146 insertions(+), 32 deletions(-) diff --git a/crates/rspack_binding_api/src/native_watcher.rs b/crates/rspack_binding_api/src/native_watcher.rs index 6a829a5accb0..1d8b03ec9d2f 100644 --- a/crates/rspack_binding_api/src/native_watcher.rs +++ b/crates/rspack_binding_api/src/native_watcher.rs @@ -230,7 +230,7 @@ struct JsEventHandler { Status, true, true, - 1, + 0, >, } @@ -239,7 +239,9 @@ impl JsEventHandler { let callback = callback .build_threadsafe_function::() .callee_handled::() - .max_queue_size::<1>() + // The executor permits at most one aggregate in flight. An unbounded + // TSFN prevents a live callback from dropping that batch on QueueFull. + .max_queue_size::<0>() .weak::() .build_callback( move |ctx: napi::threadsafe_function::ThreadSafeCallContext<_>| Ok(ctx.value), @@ -253,7 +255,7 @@ impl JsEventHandler { changed_files: rspack_util::fx_hash::FxHashSet, deleted_files: rspack_util::fx_hash::FxHashSet, generation: u32, - ) { + ) -> bool { let result = NativeWatchResult { changed_files: changed_files.into_iter().collect(), removed_files: deleted_files.into_iter().collect(), @@ -262,7 +264,7 @@ impl JsEventHandler { self.inner.call( Ok(result), napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking, - ); + ) == Status::Ok } } @@ -272,7 +274,7 @@ impl rspack_watcher::EventAggregateHandler for JsEventHandler { changed_files: rspack_util::fx_hash::FxHashSet, deleted_files: rspack_util::fx_hash::FxHashSet, ) { - self.deliver(changed_files, deleted_files, 0); + let _ = self.deliver(changed_files, deleted_files, 0); } fn on_event_handle_with_generation( @@ -281,8 +283,7 @@ impl rspack_watcher::EventAggregateHandler for JsEventHandler { deleted_files: rspack_util::fx_hash::FxHashSet, generation: u32, ) -> bool { - self.deliver(changed_files, deleted_files, generation); - true + self.deliver(changed_files, deleted_files, generation) } fn on_error(&self, error: rspack_error::Error) { diff --git a/crates/rspack_watcher/src/lib.rs b/crates/rspack_watcher/src/lib.rs index 1226345eacfc..51543e1c632a 100644 --- a/crates/rspack_watcher/src/lib.rs +++ b/crates/rspack_watcher/src/lib.rs @@ -52,8 +52,8 @@ pub trait EventAggregateHandler { /// Handle a batch of file system events. fn on_event_handle(&self, _changed_files: HashSet, _deleted_files: HashSet); - /// Handle a versioned batch. Return `true` only when delivery is asynchronous; - /// the caller must then acknowledge the generation after delivery. + /// Handle a versioned batch. Return `true` only when asynchronous delivery was + /// successfully queued; the caller must then acknowledge the generation. fn on_event_handle_with_generation( &self, changed_files: HashSet, diff --git a/crates/rspack_watcher/tests/pending_events.rs b/crates/rspack_watcher/tests/pending_events.rs index fb1aca70f8b6..b9917ce6d9e7 100644 --- a/crates/rspack_watcher/tests/pending_events.rs +++ b/crates/rspack_watcher/tests/pending_events.rs @@ -28,6 +28,24 @@ impl EventAggregateHandler for AggregateHandler { } } +struct ClosingDeliveryHandler(UnboundedSender); + +impl EventAggregateHandler for ClosingDeliveryHandler { + fn on_event_handle(&self, _changed: FxHashSet, _removed: FxHashSet) { + unreachable!("the executor should provide an aggregate generation"); + } + + fn on_event_handle_with_generation( + &self, + changed: FxHashSet, + removed: FxHashSet, + generation: u32, + ) -> bool { + let _ = self.0.send((changed, removed, generation)); + false + } +} + struct ChangeHandler(UnboundedSender<(FsEventKind, String)>); impl EventHandler for ChangeHandler { @@ -205,3 +223,56 @@ async fn pending_events_are_consumed_once_without_aggregate_replay() { watcher.close().await.expect("watcher should close"); } + +#[tokio::test] +async fn closing_aggregate_delivery_does_not_block_a_restarted_watcher() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let changed_path = ArcPath::from(temp_dir.path().join("changed.js")); + let paths = [changed_path.clone()]; + let mut watcher = FsWatcher::new( + FsWatcherOptions { + aggregate_timeout: Some(10), + ..Default::default() + }, + Default::default(), + ); + let (failed_tx, mut failed_rx) = unbounded_channel(); + let (aggregate_tx, mut aggregate_rx) = unbounded_channel(); + let (change_tx, mut change_rx) = unbounded_channel(); + + watcher + .watch( + empty_paths(), + empty_paths(), + (paths.iter().cloned(), std::iter::empty()), + SystemTime::now(), + Box::new(ClosingDeliveryHandler(failed_tx)), + Box::new(ChangeHandler(change_tx.clone())), + ) + .await; + watcher.trigger_event(&changed_path, FsEventKind::Create); + assert_eq!( + receive_changes(&mut change_rx, 1).await, + [(FsEventKind::Change, path_string(&changed_path))], + ); + let (changes, removals, failed_generation) = receive_aggregate(&mut failed_rx).await; + assert_eq!(changes, FxHashSet::from_iter([path_string(&changed_path)])); + assert!(removals.is_empty()); + + // The aggregate TSFN is unbounded, so a live callback cannot lose this batch + // to QueueFull. A false result models Closing during callback teardown; no + // consumer remains for the claimed batch, and the replacement must not wedge. + watch(&mut watcher, &paths, &aggregate_tx, &change_tx).await; + watcher.trigger_event(&changed_path, FsEventKind::Remove); + assert_eq!( + receive_changes(&mut change_rx, 1).await, + [(FsEventKind::Remove, path_string(&changed_path))], + ); + let (changes, removals, next_generation) = receive_aggregate(&mut aggregate_rx).await; + assert!(changes.is_empty()); + assert_eq!(removals, FxHashSet::from_iter([path_string(&changed_path)])); + assert_ne!(next_generation, failed_generation); + watcher.acknowledge_pending_events(next_generation); + + watcher.close().await.expect("watcher should close"); +} diff --git a/packages/rspack/src/NativeWatchFileSystem.ts b/packages/rspack/src/NativeWatchFileSystem.ts index 7729fa56e59e..0c85b9a0b63f 100644 --- a/packages/rspack/src/NativeWatchFileSystem.ts +++ b/packages/rspack/src/NativeWatchFileSystem.ts @@ -8,6 +8,13 @@ import type { WatchFileSystem, } from './util/fs'; +// Native generations are uint32. Serial-number arithmetic keeps ordering valid +// across wraparound while the native watcher has at most one batch in flight. +const isNewerGeneration = (generation: number, previous: number): boolean => { + const distance = (generation - previous) >>> 0; + return distance > 0 && distance < 0x80000000; +}; + /** * The following code is modified based on * https://github.com/webpack/watchpack/blob/332b55016b7c32dab4134f793ca71a5141bd10c1/lib/watchpack.js#L33-L57 @@ -180,7 +187,7 @@ export default class NativeWatchFileSystem implements WatchFileSystem { } if ( lastDrainedGeneration !== undefined && - result.generation <= lastDrainedGeneration + !isNewerGeneration(result.generation, lastDrainedGeneration) ) { nativeWatcher.acknowledgePendingEvents(result.generation); return; diff --git a/tests/rspack-test/NativeWatcherGeneration.test.js b/tests/rspack-test/NativeWatcherGeneration.test.js index e8faf9fbf81c..061b6a19eadf 100644 --- a/tests/rspack-test/NativeWatcherGeneration.test.js +++ b/tests/rspack-test/NativeWatcherGeneration.test.js @@ -36,30 +36,37 @@ const dependencies = () => removed: [] }); +function createWatcherHarness() { + const nativeWatcher = new FakeNativeWatcher(); + const purged = []; + const callbackChanges = []; + const compiler = rspack({ + context: __dirname, + entry: __filename, + experiments: { nativeWatcher: true } + }); + compiler.inputFileSystem.purge = path => purged.push(path); + const watchFileSystem = compiler.watchFileSystem; + watchFileSystem.getNativeWatcher = () => nativeWatcher; + + const watcher = watchFileSystem.watch( + dependencies(), + dependencies(), + dependencies(), + Date.now(), + {}, + (_error, _fileTimes, _contextTimes, changes) => + callbackChanges.push(changes), + () => {} + ); + + return { nativeWatcher, purged, callbackChanges, watcher }; +} + describe("NativeWatchFileSystem aggregate generations", () => { it("suppresses a callback superseded by a synchronous native drain", () => { - const nativeWatcher = new FakeNativeWatcher(); - const purged = []; - const callbackChanges = []; - const compiler = rspack({ - context: __dirname, - entry: __filename, - experiments: { nativeWatcher: true } - }); - compiler.inputFileSystem.purge = path => purged.push(path); - const watchFileSystem = compiler.watchFileSystem; - watchFileSystem.getNativeWatcher = () => nativeWatcher; - - const watcher = watchFileSystem.watch( - dependencies(), - dependencies(), - dependencies(), - Date.now(), - {}, - (_error, _fileTimes, _contextTimes, changes) => - callbackChanges.push(changes), - () => {} - ); + const { nativeWatcher, purged, callbackChanges, watcher } = + createWatcherHarness(); nativeWatcher.pendingDrain = { changedFiles: ["/changed"], @@ -89,4 +96,32 @@ describe("NativeWatchFileSystem aggregate generations", () => { expect(purged).toEqual(["/changed", "/next"]); expect(callbackChanges).toEqual([new Set(["/next"])]); }); + + it("accepts a newer aggregate after the native generation wraps", () => { + const { nativeWatcher, purged, callbackChanges, watcher } = + createWatcherHarness(); + + nativeWatcher.pendingDrain = { + changedFiles: ["/drained"], + removedFiles: [], + generation: 0xffffffff + }; + expect(watcher.getInfo().changes).toEqual(new Set(["/drained"])); + + nativeWatcher.onAggregate(null, { + changedFiles: ["/stale"], + removedFiles: [], + generation: 0xfffffffe + }); + nativeWatcher.onAggregate(null, { + changedFiles: ["/wrapped"], + removedFiles: [], + generation: 0 + }); + + expect(nativeWatcher.acknowledgements).toEqual([0xfffffffe, 0]); + expect(nativeWatcher.pauses).toBe(1); + expect(purged).toEqual(["/drained", "/wrapped"]); + expect(callbackChanges).toEqual([new Set(["/wrapped"])]); + }); }); From 5936572bddffdbedc3b8fb4c310a3d0b3429b4b6 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:49:42 +0000 Subject: [PATCH 14/29] fix(hmr): keep dynamic and global entry rebuilds correct --- .../rspack_binding_api/src/compilation/mod.rs | 41 +- .../src/dependencies/entry_dependency.rs | 3 +- crates/rspack_binding_api/src/lib.rs | 9 +- .../artifacts/build_chunk_graph_artifact.rs | 238 +++++++- crates/rspack_plugin_dynamic_entry/src/lib.rs | 26 +- .../BindingEntryDependency.test.js | 229 ++++++++ .../ChunkGraphEntryOptions.test.js | 510 ++++++++++++++++++ 7 files changed, 1018 insertions(+), 38 deletions(-) create mode 100644 tests/rspack-test/BindingEntryDependency.test.js create mode 100644 tests/rspack-test/ChunkGraphEntryOptions.test.js diff --git a/crates/rspack_binding_api/src/compilation/mod.rs b/crates/rspack_binding_api/src/compilation/mod.rs index 354e82e7fc37..0d26c8a11863 100644 --- a/crates/rspack_binding_api/src/compilation/mod.rs +++ b/crates/rspack_binding_api/src/compilation/mod.rs @@ -802,20 +802,19 @@ impl JsCompilation { Some(js_opts) => js_opts.into(), None => EntryOptions::default(), }; - let dependency = if let Some(map) = entry_dependencies_map.get(&js_dependency.request) - && let Some(dependency) = map.get(&options) - { + let cache_key = ( + js_context.clone(), + js_dependency.request.clone(), + options.name.clone(), + layer.clone(), + ); + let dependency = if let Some(dependency) = entry_dependencies_map.get(&cache_key) { js_dependency.dependency_id = Some(*dependency.id()); dependency.clone() } else { - let dependency = js_dependency.resolve(js_context.into(), layer)?; - if let Some(map) = entry_dependencies_map.get_mut(&js_dependency.request) { - map.insert(options.clone(), dependency.clone()); - } else { - let mut map = FxHashMap::default(); - map.insert(options.clone(), dependency.clone()); - entry_dependencies_map.insert(js_dependency.request.clone(), map); - } + let dependency = + js_dependency.resolve(js_context.into(), layer, options.name.is_none())?; + entry_dependencies_map.insert(cache_key, dependency.clone()); dependency }; Ok((dependency, options)) @@ -906,20 +905,18 @@ impl JsCompilation { Some(js_opts) => js_opts.into(), None => EntryOptions::default(), }; - let dependency = if let Some(map) = include_dependencies_map.get(&js_dependency.request) - && let Some(dependency) = map.get(&options) - { + let cache_key = ( + js_context.clone(), + js_dependency.request.clone(), + options.name.clone(), + layer.clone(), + ); + let dependency = if let Some(dependency) = include_dependencies_map.get(&cache_key) { js_dependency.dependency_id = Some(*dependency.id()); dependency.clone() } else { - let dependency = js_dependency.resolve(js_context.into(), layer)?; - if let Some(map) = include_dependencies_map.get_mut(&js_dependency.request) { - map.insert(options.clone(), dependency.clone()); - } else { - let mut map = FxHashMap::default(); - map.insert(options.clone(), dependency.clone()); - include_dependencies_map.insert(js_dependency.request.clone(), map); - } + let dependency = js_dependency.resolve(js_context.into(), layer, true)?; + include_dependencies_map.insert(cache_key, dependency.clone()); dependency }; Ok((dependency, options)) diff --git a/crates/rspack_binding_api/src/dependencies/entry_dependency.rs b/crates/rspack_binding_api/src/dependencies/entry_dependency.rs index 81e1873493c8..36bfcb147a32 100644 --- a/crates/rspack_binding_api/src/dependencies/entry_dependency.rs +++ b/crates/rspack_binding_api/src/dependencies/entry_dependency.rs @@ -11,6 +11,7 @@ impl EntryDependency { &mut self, context: rspack_core::Context, layer: Option, + is_global: bool, ) -> napi::Result> { match &self.dependency_id { Some(dependency_id) => Err(napi::Error::from_reason(format!( @@ -21,7 +22,7 @@ impl EntryDependency { self.request.clone(), context, layer, - false, + is_global, )) as rspack_core::BoxDependency; self.dependency_id = Some(*dependency.id()); Ok(dependency) diff --git a/crates/rspack_binding_api/src/lib.rs b/crates/rspack_binding_api/src/lib.rs index 6048afe0d734..cc581a6c914f 100644 --- a/crates/rspack_binding_api/src/lib.rs +++ b/crates/rspack_binding_api/src/lib.rs @@ -111,8 +111,7 @@ use std::{ use napi::{CallContext, bindgen_prelude::*}; pub use raw_options::{CustomPluginBuilder, register_custom_plugin}; use rspack_core::{ - BoxDependency, Compilation, CompilerId, CompilerPlatform, EntryOptions, ModuleIdentifier, - PluginExt, + BoxDependency, Compilation, CompilerId, CompilerPlatform, ModuleIdentifier, PluginExt, }; use rspack_error::Diagnostic; use rspack_fs::{IntermediateFileSystem, NativeFileSystem, ReadableFileSystem}; @@ -155,6 +154,8 @@ thread_local! { static COMPILER_REFERENCES: RefCell>> = Default::default(); } +type EntryDependencyCacheKey = (String, String, Option, Option); + #[js_function(1)] fn cleanup_revoked_modules(ctx: CallContext) -> Result<()> { let external = ctx.get::<&mut External<(CompilerId, Vec)>>(0)?; @@ -173,8 +174,8 @@ struct JsCompiler { // call drop manually to avoid unnecessary drop overhead in cli build compiler: ManuallyDrop, state: CompilerState, - include_dependencies_map: FxHashMap>, - entry_dependencies_map: FxHashMap>, + include_dependencies_map: FxHashMap, + entry_dependencies_map: FxHashMap, compiler_context: Arc, virtual_file_store: Option>>, } diff --git a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index aea50280fa06..4498bcd8a66f 100644 --- a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs +++ b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs @@ -1,6 +1,7 @@ use std::mem; use futures::Future; +use itertools::Itertools; use rspack_collections::{IdentifierIndexMap, IdentifierMap}; use rspack_error::Result; use rspack_util::{fx_hash::FxIndexMap, tracing_preset::TRACING_BENCH_TARGET}; @@ -9,7 +10,8 @@ use tracing::instrument; use crate::{ ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupKind, ChunkGroupUkey, - ChunkUkey, Compilation, DependenciesBlock, GroupOptions, Logger, + ChunkUkey, Compilation, DependenciesBlock, EntryDependency, EntryOptions, Filename, GroupOptions, + Logger, ModuleDependency, ModuleIdentifier, PublicPath, build_chunk_graph::code_splitter::{ CodeSplitter, DependenciesBlockIdentifier, get_active_state_of_connections, prepare_module_connection_map, @@ -29,6 +31,8 @@ pub struct BuildChunkGraphArtifact { pub named_chunks: HashMap, pub(crate) code_splitter: CodeSplitter, pub module_idx: IdentifierMap<(u32, u32)>, + global_include_modules: Vec, + entry_include_modules: FxIndexMap>, } impl BuildChunkGraphArtifact { @@ -57,6 +61,80 @@ impl BuildChunkGraphArtifact { return false; } + let module_graph = this_compilation.get_module_graph(); + for (name, entry) in &this_compilation.entries { + let Some(previous_entrypoint) = self + .entrypoints + .get(name) + .and_then(|ukey| self.chunk_group_by_ukey.get(ukey)) + else { + logger.log(format!( + "entrypoint missing from cached chunk graph: {name}" + )); + return false; + }; + if !previous_entrypoint + .kind + .get_entry_options() + .is_some_and(|options| same_entry_options_topology(options, &entry.options)) + { + logger.log(format!("entrypoint options change detected: {name}")); + return false; + } + + let current_entry_modules = this_compilation + .global_entry + .dependencies + .iter() + .chain(&entry.dependencies) + .filter_map(|dependency| module_graph.module_identifier_by_dependency_id(dependency)) + .copied() + .unique(); + let previous_entry_modules = self + .chunk_graph + .get_chunk_entry_modules_with_chunk_group_iterable( + &previous_entrypoint.get_entrypoint_chunk(), + ) + .keys() + .copied(); + + if !current_entry_modules.eq(previous_entry_modules) { + logger.log(format!("entrypoint modules change detected: {name}")); + return false; + } + + let current_entry_requests = this_compilation + .global_entry + .dependencies + .iter() + .chain(&entry.dependencies) + .map(|dependency| { + module_graph + .dependency_by_id(dependency) + .as_any() + .downcast_ref::() + .map(|dependency| dependency.request()) + }); + let previous_entry_requests = previous_entrypoint + .origins() + .iter() + .map(|origin| origin.request.as_deref()); + + if !current_entry_requests.eq(previous_entry_requests) { + logger.log(format!("entrypoint origins change detected: {name}")); + return false; + } + } + + let (global_include_modules, entry_include_modules) = + collect_entry_include_modules(this_compilation); + if self.global_include_modules != global_include_modules + || self.entry_include_modules != entry_include_modules + { + logger.log("entrypoint include modules change detected, rebuilding chunk graph"); + return false; + } + let Some(mutations) = this_compilation .incremental .mutations_read(IncrementalPasses::BUILD_MODULE_GRAPH) @@ -75,7 +153,6 @@ impl BuildChunkGraphArtifact { return false; } - let module_graph = this_compilation.get_module_graph(); let module_graph_cache = &this_compilation.module_graph_cache_artifact; let affected_modules = mutations.get_affected_modules_with_module_graph(module_graph); let previous_modules_map = &this_compilation @@ -240,6 +317,150 @@ impl BuildChunkGraphArtifact { self.named_chunks.clear(); self.set_code_splitter(Default::default()); self.module_idx.clear(); + self.global_include_modules.clear(); + self.entry_include_modules.clear(); + } +} + +fn collect_entry_include_modules( + compilation: &Compilation, +) -> ( + Vec, + FxIndexMap>, +) { + let module_graph = compilation.get_module_graph(); + let mut global_includes = compilation + .global_entry + .include_dependencies + .iter() + .filter_map(|dependency| module_graph.module_identifier_by_dependency_id(dependency)) + .copied() + .collect::>(); + global_includes.sort_unstable(); + global_includes.dedup(); + + let entry_includes = compilation + .entries + .iter() + .filter_map(|(name, entry)| { + if entry.include_dependencies.is_empty() { + return None; + } + + let mut entry_includes = entry + .include_dependencies + .iter() + .filter_map(|dependency| module_graph.module_identifier_by_dependency_id(dependency)) + .copied() + .collect::>(); + entry_includes.sort_unstable(); + entry_includes.dedup(); + + Some((name.clone(), entry_includes)) + }) + .collect(); + + (global_includes, entry_includes) +} + +fn same_entry_options_topology(previous: &EntryOptions, current: &EntryOptions) -> bool { + let EntryOptions { + name: previous_name, + runtime: previous_runtime, + chunk_loading: previous_chunk_loading, + wasm_loading: previous_wasm_loading, + async_chunks: previous_async_chunks, + public_path: previous_public_path, + base_uri: previous_base_uri, + filename: previous_filename, + library: previous_library, + depend_on: previous_depend_on, + layer: previous_layer, + } = previous; + let EntryOptions { + name: current_name, + runtime: current_runtime, + chunk_loading: current_chunk_loading, + wasm_loading: current_wasm_loading, + async_chunks: current_async_chunks, + public_path: current_public_path, + base_uri: current_base_uri, + filename: current_filename, + library: current_library, + depend_on: current_depend_on, + layer: current_layer, + } = current; + + previous_name == current_name + && previous_runtime == current_runtime + && previous_chunk_loading == current_chunk_loading + && previous_wasm_loading == current_wasm_loading + && previous_async_chunks == current_async_chunks + && same_public_path_shape(previous_public_path, current_public_path) + && previous_base_uri == current_base_uri + && same_filename_shape(previous_filename, current_filename) + && previous_library == current_library + && previous_depend_on == current_depend_on + && previous_layer == current_layer +} + +fn same_public_path_shape(previous: &Option, current: &Option) -> bool { + match (previous, current) { + (Some(PublicPath::Filename(previous)), Some(PublicPath::Filename(current))) => { + same_filename(previous, current) + } + _ => previous == current, + } +} + +fn same_filename_shape(previous: &Option, current: &Option) -> bool { + match (previous, current) { + (Some(previous), Some(current)) => same_filename(previous, current), + _ => previous == current, + } +} + +fn same_filename(previous: &Filename, current: &Filename) -> bool { + previous == current || (previous.template().is_none() && current.template().is_none()) +} + +fn refresh_entrypoint_options(compilation: &mut Compilation) { + let artifact = &mut compilation.build_chunk_graph_artifact; + let mut requires_full_chunk_assets = false; + + for (name, entry) in &compilation.entries { + let entrypoint_ukey = *artifact + .entrypoints + .get(name) + .expect("cached entrypoint should exist"); + let entrypoint = artifact + .chunk_group_by_ukey + .expect_get_mut(&entrypoint_ukey); + let entrypoint_chunk = entrypoint.get_entrypoint_chunk(); + let ChunkGroupKind::Entrypoint { options, .. } = &mut entrypoint.kind else { + unreachable!("cached entrypoint should have entrypoint options"); + }; + **options = entry.options.clone(); + + let filename = entry.options.filename.clone(); + requires_full_chunk_assets |= filename + .as_ref() + .is_some_and(Filename::has_hash_placeholder); + artifact + .chunk_by_ukey + .expect_get_mut(&entrypoint_chunk) + .set_filename_template(filename); + } + + if requires_full_chunk_assets + && let Some(diagnostic) = compilation.incremental.disable_passes( + IncrementalPasses::CHUNK_ASSET, + "Chunk filename that dependent on full hash", + "chunk filename that dependent on full hash is not supported in incremental compilation", + ) + && let Some(diagnostic) = diagnostic + { + compilation.push_diagnostic(diagnostic); } } @@ -270,6 +491,8 @@ where .can_skip_rebuilding(compilation); if no_change { + refresh_entrypoint_options(compilation); + let module_idx = &compilation.build_chunk_graph_artifact.module_idx; let module_graph = compilation .build_module_graph_artifact @@ -297,6 +520,11 @@ where map.insert(*mid, (pre, post)); } compilation.build_chunk_graph_artifact.module_idx = map; + let (global_include_modules, entry_include_modules) = collect_entry_include_modules(compilation); + compilation + .build_chunk_graph_artifact + .global_include_modules = global_include_modules; + compilation.build_chunk_graph_artifact.entry_include_modules = entry_include_modules; Ok(()) } @@ -318,6 +546,12 @@ impl ArtifactExt for BuildChunkGraphArtifact { s.spawn(|_| { new.entrypoints.clone_from(&old.entrypoints); new.module_idx.clone_from(&old.module_idx); + new + .global_include_modules + .clone_from(&old.global_include_modules); + new + .entry_include_modules + .clone_from(&old.entry_include_modules); }); }); } diff --git a/crates/rspack_plugin_dynamic_entry/src/lib.rs b/crates/rspack_plugin_dynamic_entry/src/lib.rs index 263b9f044ff2..4a80c4f3b7f3 100644 --- a/crates/rspack_plugin_dynamic_entry/src/lib.rs +++ b/crates/rspack_plugin_dynamic_entry/src/lib.rs @@ -19,6 +19,7 @@ pub struct EntryDynamicResult { type EntryDynamic = Box Fn() -> BoxFuture<'static, Result>> + Sync + Send>; +type EntryDependencyKey = (Option, Option); pub struct DynamicEntryPluginOptions { pub context: Context, @@ -33,7 +34,8 @@ pub struct DynamicEntryPlugin { entry: EntryDynamic, // Need "cache" the dependency to tell incremental that this entry dependency is not changed // so it can be reused and skip the module make - imported_dependencies: AtomicRefCell, FxHashMap>>, + imported_dependencies: + AtomicRefCell, FxHashMap>>, } impl DynamicEntryPlugin { @@ -62,22 +64,25 @@ async fn make(&self, compilation: &mut Compilation) -> Result<()> { .mutations_readable(IncrementalPasses::BUILD_MODULE_GRAPH) { let mut imported_dependencies = self.imported_dependencies.borrow_mut(); - let mut next_imported_dependencies: FxHashMap, FxHashMap> = - Default::default(); + let mut next_imported_dependencies: FxHashMap< + Arc, + FxHashMap, + > = Default::default(); for EntryDynamicResult { import, options } in decs { for entry in import { + let dependency_key = (options.name.clone(), options.layer.clone()); let module_graph = compilation.get_module_graph(); let entry_dependency: BoxDependency = if let Some(map) = imported_dependencies.get(entry.as_str()) - && let Some(dependency_id) = map.get(&options) + && let Some(dependency_id) = map.get(&dependency_key) && let Some(dependency) = internal::try_dependency_by_id(module_graph, dependency_id) { next_imported_dependencies .entry(entry.into()) .or_default() - .insert(options.clone(), *dependency_id); + .insert(dependency_key, *dependency_id); dependency.clone() } else { let dependency: BoxDependency = Box::new(EntryDependency::new( @@ -89,7 +94,7 @@ async fn make(&self, compilation: &mut Compilation) -> Result<()> { next_imported_dependencies .entry(entry.into()) .or_default() - .insert(options.clone(), *dependency.id()); + .insert(dependency_key, *dependency.id()); dependency }; compilation @@ -104,10 +109,13 @@ async fn make(&self, compilation: &mut Compilation) -> Result<()> { // next Hot rebuild reallocates dep ids and breaks every downstream // lookup that relies on dep id continuity across compiles. let mut imported_dependencies = self.imported_dependencies.borrow_mut(); - let mut next_imported_dependencies: FxHashMap, FxHashMap> = - Default::default(); + let mut next_imported_dependencies: FxHashMap< + Arc, + FxHashMap, + > = Default::default(); for EntryDynamicResult { import, options } in decs { for entry in import { + let dependency_key = (options.name.clone(), options.layer.clone()); let entry_dependency: BoxDependency = Box::new(EntryDependency::new( entry.clone(), self.context.clone(), @@ -117,7 +125,7 @@ async fn make(&self, compilation: &mut Compilation) -> Result<()> { next_imported_dependencies .entry(entry.clone().into()) .or_default() - .insert(options.clone(), *entry_dependency.id()); + .insert(dependency_key, *entry_dependency.id()); compilation .add_entry(entry_dependency, options.clone()) .await?; diff --git a/tests/rspack-test/BindingEntryDependency.test.js b/tests/rspack-test/BindingEntryDependency.test.js new file mode 100644 index 000000000000..aa1bee23c766 --- /dev/null +++ b/tests/rspack-test/BindingEntryDependency.test.js @@ -0,0 +1,229 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { rspack, lazyCompilationMiddleware } = require("@rspack/core"); + +function write(root, filename, content) { + const file = path.join(root, filename); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function run(compiler) { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats.hasErrors()) { + return reject(new Error(stats.toString({ all: false, errors: true }))); + } + resolve(stats.compilation); + }); + }); +} + +function watchOnce(compiler) { + let watching; + const result = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for a lazy compilation")), + 10000 + ); + watching = compiler.watch({}, (error, stats) => { + clearTimeout(timeout); + if (error) return reject(error); + if (stats.hasErrors()) { + return reject(new Error(stats.toString({ all: false, errors: true }))); + } + resolve(stats.compilation); + }); + }); + return { result, watching }; +} + +function moduleIdentifiers(compilation) { + return [...compilation.modules].map(module => module.identifier()); +} + +async function close(compiler) { + await new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); +} + +describe("binding entry dependency identity", () => { + it("keeps unnamed entries and named/global includes eager during lazy compilation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rspack-binding-lazy-")); + write(root, "src/main.js", "globalThis.named = 'NAMED_ENTRY';\n"); + write(root, "src/global.js", "globalThis.global = 'GLOBAL_ENTRY';\n"); + write( + root, + "src/named-include.js", + "globalThis.namedInclude = 'NAMED_INCLUDE';\n" + ); + write( + root, + "src/global-include.js", + "globalThis.globalInclude = 'GLOBAL_INCLUDE';\n" + ); + const compiler = rspack({ + context: root, + mode: "development", + target: "web", + devtool: false, + cache: true, + incremental: true, + entry: { main: "./src/main.js" }, + lazyCompilation: { entries: true, imports: false }, + output: { + path: path.join(root, "dist"), + filename: "main.js", + chunkFilename: "[name].js" + }, + optimization: { + minimize: false, + splitChunks: false, + moduleIds: "named", + chunkIds: "named" + }, + plugins: [ + { + apply(currentCompiler) { + currentCompiler.hooks.make.tapPromise( + "binding-global-entry", + compilation => + new Promise((resolve, reject) => { + compilation.addEntry( + currentCompiler.context, + rspack.EntryPlugin.createDependency("./src/global.js"), + {}, + error => (error ? reject(error) : resolve()) + ); + }) + ); + currentCompiler.hooks.finishMake.tapPromise( + "binding-includes", + compilation => + Promise.all( + [ + ["./src/named-include.js", { name: "main" }], + ["./src/global-include.js", {}] + ].map( + ([request, options]) => + new Promise((resolve, reject) => { + compilation.addInclude( + currentCompiler.context, + rspack.EntryPlugin.createDependency(request), + options, + error => (error ? reject(error) : resolve()) + ); + }) + ) + ) + ); + } + } + ] + }); + lazyCompilationMiddleware(compiler); + const { result, watching } = watchOnce(compiler); + + try { + const compilation = await result; + const modules = moduleIdentifiers(compilation); + const source = compilation.getAsset("main.js").source.source().toString(); + const isProxyFor = filename => + modules.some( + identifier => + identifier.includes("lazy-compilation-proxy") && + identifier.includes(filename) + ); + + expect(isProxyFor("global.js")).toBe(false); + expect(isProxyFor("named-include.js")).toBe(false); + expect(isProxyFor("global-include.js")).toBe(false); + expect(isProxyFor("main.js")).toBe(true); + expect(source).toContain("GLOBAL_ENTRY"); + expect(source).toContain("NAMED_INCLUDE"); + expect(source).toContain("GLOBAL_INCLUDE"); + expect(source).not.toContain("NAMED_ENTRY"); + } finally { + await new Promise((resolve, reject) => + watching.close(error => (error ? reject(error) : resolve())) + ); + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it.each([ + ["addEntry", "make", "entry.js"], + ["addInclude", "finishMake", "include.js"] + ])( + "keeps the raw context in the %s dependency cache key", + async (method, hook, request) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), `rspack-binding-${method}-`) + ); + const contextA = path.join(root, "a"); + const contextB = path.join(root, "b"); + write(root, "src/main.js", "globalThis.main = true;\n"); + write(contextA, request, "globalThis.fromA = 'CONTEXT_A_PAYLOAD';\n"); + write(contextB, request, "globalThis.fromB = 'CONTEXT_B_PAYLOAD';\n"); + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: { main: "./src/main.js" }, + output: { path: path.join(root, "dist"), filename: "main.js" }, + optimization: { + minimize: false, + splitChunks: false, + moduleIds: "named" + }, + plugins: [ + { + apply(currentCompiler) { + currentCompiler.hooks[hook].tapPromise( + `binding-${method}-context`, + compilation => + Promise.all( + [contextA, contextB].map( + context => + new Promise((resolve, reject) => { + compilation[method]( + context, + rspack.EntryPlugin.createDependency(`./${request}`), + { name: "main" }, + error => (error ? reject(error) : resolve()) + ); + }) + ) + ) + ); + } + } + ] + }); + + try { + const compilation = await run(compiler); + const modules = moduleIdentifiers(compilation).join("\n"); + const source = compilation + .getAsset("main.js") + .source.source() + .toString(); + + expect(modules).toContain(path.join("a", request)); + expect(modules).toContain(path.join("b", request)); + expect(source).toContain("CONTEXT_A_PAYLOAD"); + expect(source).toContain("CONTEXT_B_PAYLOAD"); + } finally { + await close(compiler); + fs.rmSync(root, { force: true, recursive: true }); + } + } + ); +}); diff --git a/tests/rspack-test/ChunkGraphEntryOptions.test.js b/tests/rspack-test/ChunkGraphEntryOptions.test.js new file mode 100644 index 000000000000..270254a5e6cc --- /dev/null +++ b/tests/rspack-test/ChunkGraphEntryOptions.test.js @@ -0,0 +1,510 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const { rspack } = require("@rspack/core"); + +const fixtureRoot = path.resolve(__dirname, "js/chunk-graph-entry-options"); + +function write(root, filename, content) { + const file = path.join(root, filename); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function run(compiler) { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats.hasErrors()) { + return reject(new Error(stats.toString({ all: false, errors: true }))); + } + resolve(stats.compilation); + }); + }); +} + +function rebuild(compiler, modifiedFiles) { + return new Promise((resolve, reject) => { + compiler.__internal__rebuild(new Set(modifiedFiles), new Set(), error => { + if (error) return reject(error); + const compilation = compiler._lastCompilation; + if (compilation.errors.length > 0) { + return reject(new Error(compilation.errors.join("\n"))); + } + resolve(compilation); + }); + }); +} + +function evaluateOrder(compilation, filename) { + const context = { globalThis: { order: [] } }; + vm.runInNewContext( + compilation.getAsset(filename).source.source().toString(), + context + ); + return context.globalThis.order; +} + +function originRequests(compilation, chunkName) { + return compilation + .getStats() + .toJson({ all: false, chunks: true, chunkOrigins: true }) + .chunks.find(chunk => chunk.names.includes(chunkName)) + .origins.map(origin => origin.request); +} + +function codeSplittingMessages(compilation) { + return ( + compilation.getStats().toJson({ all: false, logging: "verbose" }).logging?.[ + "rspack.Compilation.codeSplittingCache" + ]?.entries ?? [] + ).map(entry => entry.message); +} + +async function close(compiler) { + await new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); +} + +describe("incremental chunk graph entry roots", () => { + afterAll(() => { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + }); + + it("rebuilds the chunk graph when dynamic entry options change", async () => { + const root = path.join(fixtureRoot, "filename"); + fs.rmSync(root, { force: true, recursive: true }); + write(root, "src/index.js", "import './leaf';\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let filename = "before.js"; + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: () => ({ main: { import: "./src/index.js", filename } }), + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false } + }); + + try { + const initial = await run(compiler); + expect(initial.getAsset("before.js")).toBeDefined(); + filename = "after.js"; + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + + expect(updated.getAsset("after.js")).toBeDefined(); + expect(updated.getAsset("before.js")).toBeUndefined(); + } finally { + await close(compiler); + } + }); + + it("keeps dynamic entry import targets and execution order current", async () => { + const root = path.join(fixtureRoot, "imports"); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/a.js", + "import './a-child'; globalThis.order.push('A');\n" + ); + write( + root, + "src/b.js", + "import './b-child'; globalThis.order.push('B');\n" + ); + write(root, "src/a-child.js", "export const value = 'a';\n"); + write(root, "src/b-child.js", "export const value = 'b';\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let imports = ["./src/a.js", "./src/b.js"]; + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: () => ({ + keeper: { import: ["./src/a.js", "./src/b.js", "./src/leaf.js"] }, + main: { import: imports } + }), + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false } + }); + + try { + const initial = await run(compiler); + expect(evaluateOrder(initial, "main.js")).toEqual(["A", "B"]); + imports = ["./src/b.js", "./src/a.js"]; + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + + expect(evaluateOrder(updated, "main.js")).toEqual(["B", "A"]); + } finally { + await close(compiler); + } + }); + + it("keeps unnamed global entry targets current when another entry retains both modules", async () => { + const root = path.join(fixtureRoot, "global-entry"); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/a.js", + "import './a-child'; globalThis.order.push('A');\n" + ); + write( + root, + "src/b.js", + "import './b-child'; globalThis.order.push('B');\n" + ); + write(root, "src/a-child.js", "export const value = 'a';\n"); + write(root, "src/b-child.js", "export const value = 'b';\n"); + write(root, "src/main.js", "globalThis.order.push('M');\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let globalEntry = "./src/a.js"; + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: { + keeper: { import: ["./src/a.js", "./src/b.js", "./src/leaf.js"] }, + main: { import: "./src/main.js" } + }, + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false }, + plugins: [ + { + apply(compiler) { + compiler.hooks.make.tapPromise( + "global-entry-regression", + compilation => { + return new Promise((resolve, reject) => { + compilation.addEntry( + compiler.context, + rspack.EntryPlugin.createDependency(globalEntry), + {}, + error => (error ? reject(error) : resolve()) + ); + }); + } + ); + } + } + ] + }); + + try { + const initial = await run(compiler); + expect(evaluateOrder(initial, "main.js")).toEqual(["A", "M"]); + globalEntry = "./src/b.js"; + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + + expect(evaluateOrder(updated, "main.js")).toEqual(["B", "M"]); + } finally { + await close(compiler); + } + }); + + it("keeps dynamic entry request attribution current when spelling changes", async () => { + const root = path.join(fixtureRoot, "origin-request"); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/a.js", + "import './a-child'; globalThis.order.push('A');\n" + ); + write(root, "src/a-child.js", "export const value = 'a';\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let entryRequest = "./src/a.js"; + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: () => ({ + keeper: { import: ["./src/a.js", "./src/leaf.js"] }, + main: { import: entryRequest } + }), + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false } + }); + + try { + const initial = await run(compiler); + expect(originRequests(initial, "main")).toContain("./src/a.js"); + entryRequest = "./src/./a.js"; + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + + expect(originRequests(updated, "main")).toContain("./src/./a.js"); + expect(originRequests(updated, "main")).not.toContain("./src/a.js"); + } finally { + await close(compiler); + } + }); + + it("reuses an unchanged chunk graph when a global and named entry share a root", async () => { + const root = path.join(fixtureRoot, "duplicate-root"); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/a.js", + "import './a-child'; globalThis.order.push('A');\n" + ); + write(root, "src/a-child.js", "export const value = 'a';\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: { + keeper: { import: "./src/leaf.js" }, + main: { import: "./src/a.js" } + }, + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false }, + plugins: [ + { + apply(compiler) { + compiler.hooks.make.tapPromise( + "duplicate-root-regression", + compilation => { + return new Promise((resolve, reject) => { + compilation.addEntry( + compiler.context, + rspack.EntryPlugin.createDependency("./src/a.js"), + {}, + error => (error ? reject(error) : resolve()) + ); + }); + } + ); + } + } + ] + }); + + try { + await run(compiler); + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + + expect(codeSplittingMessages(updated)).toEqual([]); + } finally { + await close(compiler); + } + }); + + it.each([ + ["named", { name: "main" }], + ["global", {}] + ])( + "keeps %s include roots current when another entry retains both modules", + async (kind, options) => { + const root = path.join(fixtureRoot, `${kind}-include`); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/a.js", + "import './a-child'; globalThis.order.push('A');\n" + ); + write( + root, + "src/b.js", + "import './b-child'; globalThis.order.push('B');\n" + ); + write(root, "src/a-child.js", "export const value = 'a';\n"); + write(root, "src/b-child.js", "export const value = 'b';\n"); + write(root, "src/main.js", "globalThis.order.push('M');\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let included = "./src/a.js"; + const compiler = rspack({ + context: root, + mode: "development", + target: "node", + devtool: false, + cache: true, + incremental: true, + entry: { + keeper: { import: ["./src/a.js", "./src/b.js", "./src/leaf.js"] }, + main: { import: "./src/main.js" } + }, + output: { path: path.join(root, "dist"), filename: "[name].js" }, + optimization: { minimize: false, splitChunks: false }, + plugins: [ + { + apply(compiler) { + compiler.hooks.finishMake.tapPromise( + `${kind}-include-regression`, + compilation => + new Promise((resolve, reject) => { + compilation.addInclude( + compiler.context, + rspack.EntryPlugin.createDependency(included), + options, + error => (error ? reject(error) : resolve()) + ); + }) + ); + } + } + ] + }); + + try { + const initial = await run(compiler); + const initialSource = initial + .getAsset("main.js") + .source.source() + .toString(); + expect(initialSource).toContain('"./src/a.js"'); + expect(initialSource).not.toContain('"./src/b.js"'); + included = "./src/b.js"; + const leaf = write( + root, + "src/leaf.js", + "export const value = 'after';\n" + ); + const updated = await rebuild(compiler, [leaf]); + const updatedSource = updated + .getAsset("main.js") + .source.source() + .toString(); + + expect(updatedSource).toContain('"./src/b.js"'); + expect(updatedSource).not.toContain('"./src/a.js"'); + } finally { + await close(compiler); + } + } + ); + + it.each(["shared", "captured"])( + "refreshes %s dynamic filename and publicPath functions while reusing the chunk graph", + async functionMode => { + const root = path.join(fixtureRoot, `function-${functionMode}`); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/main.js", + "import(/* webpackChunkName: 'lazy' */ './lazy'); globalThis.main = true;\n" + ); + write(root, "src/lazy.js", "export const value = 'lazy';\n"); + write(root, "src/leaf.js", "export const value = 'before';\n"); + let filenameValue = "before-main.js"; + let publicPathValue = "/before-assets/"; + const sharedFilename = () => filenameValue; + const sharedPublicPath = () => publicPathValue; + const compiler = rspack({ + context: root, + mode: "development", + target: "web", + devtool: false, + cache: true, + experiments: { cache: true }, + incremental: true, + entry: () => { + const capturedFilename = filenameValue; + const capturedPublicPath = publicPathValue; + return { + keeper: { import: "./src/leaf.js" }, + main: { + import: "./src/main.js", + filename: + functionMode === "shared" + ? sharedFilename + : () => capturedFilename, + publicPath: + functionMode === "shared" + ? sharedPublicPath + : () => capturedPublicPath + } + }; + }, + output: { + path: path.join(root, "dist"), + filename: "[name].js", + chunkFilename: "[name].js" + }, + optimization: { + minimize: false, + splitChunks: false, + moduleIds: "named", + chunkIds: "named" + } + }); + + try { + const initial = await run(compiler); + expect(initial.getAsset("before-main.js")).toBeDefined(); + expect( + initial.getAsset("before-main.js").source.source().toString() + ).toContain("/before-assets/"); + + let leaf = write( + root, + "src/leaf.js", + "export const value = 'stable-edit';\n" + ); + const stable = await rebuild(compiler, [leaf]); + expect(codeSplittingMessages(stable)).toEqual([]); + expect(stable.getAsset("before-main.js")).toBeDefined(); + + filenameValue = "after-main.js"; + publicPathValue = "/after-assets/"; + leaf = write( + root, + "src/leaf.js", + "export const value = 'changed-edit';\n" + ); + const updated = await rebuild(compiler, [leaf]); + const updatedSource = updated + .getAsset("after-main.js") + .source.source() + .toString(); + + expect(codeSplittingMessages(updated)).toEqual([]); + expect(updated.getAsset("before-main.js")).toBeUndefined(); + expect(updatedSource).toContain("/after-assets/"); + expect(updatedSource).not.toContain("/before-assets/"); + } finally { + await close(compiler); + } + } + ); +}); From f861edddabbba059256290c56082feefa01d0200 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:49:47 +0000 Subject: [PATCH 15/29] fix(runtime): keep full-hash chunk filenames correct and cheap --- .../rspack_plugin_extract_css/src/plugin.rs | 2 + .../src/runtime_module/get_chunk_filename.rs | 24 ++--- .../src/runtime_plugin.rs | 38 +++++++- .../rspack-test/RuntimeChunkFilename.test.js | 88 +++++++++++++++++++ 4 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 tests/rspack-test/RuntimeChunkFilename.test.js diff --git a/crates/rspack_plugin_extract_css/src/plugin.rs b/crates/rspack_plugin_extract_css/src/plugin.rs index 41e1bb95016e..514f0ced0a17 100644 --- a/crates/rspack_plugin_extract_css/src/plugin.rs +++ b/crates/rspack_plugin_extract_css/src/plugin.rs @@ -548,6 +548,7 @@ async fn runtime_requirement_in_tree( if has_hot_update || runtime_requirements.contains(RuntimeGlobals::ENSURE_CHUNK_HANDLERS) { let filename = self.options.filename.clone(); let chunk_filename = self.options.chunk_filename.clone(); + let needs_full_hash = filename.has_hash_placeholder() || chunk_filename.has_hash_placeholder(); let runtime_template = compilation.runtime_template.create_chunk_code_template(); let global = format!("{}.miniCssF", runtime_template.render_runtime_argument()); @@ -558,6 +559,7 @@ async fn runtime_requirement_in_tree( ChunkFilenameKind { content_type: "css", runtime_module_name: "mini-css", + needs_full_hash, }, SOURCE_TYPE[0], global, diff --git a/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs b/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs index 4e92e85769e0..50e12bc0902c 100644 --- a/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs +++ b/crates/rspack_plugin_runtime/src/runtime_module/get_chunk_filename.rs @@ -22,6 +22,7 @@ type GetFilenameForChunk = Box Option pub struct ChunkFilenameKind { pub content_type: &'static str, pub runtime_module_name: &'static str, + pub needs_full_hash: bool, } #[impl_runtime_module] @@ -35,6 +36,7 @@ pub struct GetChunkFilenameRuntimeModule { #[cacheable(with=Unsupported)] filename_for_chunk: GetFilenameForChunk, chunk_ukey: ChunkUkey, + needs_full_hash: bool, } impl fmt::Debug for GetChunkFilenameRuntimeModule { @@ -47,6 +49,7 @@ impl fmt::Debug for GetChunkFilenameRuntimeModule { .field("global", &self.global) .field("all_chunks", &"...") .field("chunk_ukey", &self.chunk_ukey) + .field("needs_full_hash", &self.needs_full_hash) .finish() } } @@ -75,14 +78,16 @@ impl GetChunkFilenameRuntimeModule { Box::new(all_chunks), Box::new(filename_for_chunk), chunk_ukey, + kind.needs_full_hash, ) } fn get_filename_chunks(&self, compilation: &Compilation) -> Option> { + let chunk_ukey = self.chunk().unwrap_or(self.chunk_ukey); compilation .build_chunk_graph_artifact .chunk_by_ukey - .get(&self.chunk_ukey) + .get(&chunk_ukey) .map(|chunk| { let runtime_requirements = get_chunk_runtime_requirements(compilation, &chunk.ukey()); let mut chunks = if (self.all_chunks)(runtime_requirements) { @@ -126,24 +131,11 @@ impl GetChunkFilenameRuntimeModule { impl RuntimeModule for GetChunkFilenameRuntimeModule { fn runtime_requirements( &self, - compilation: &Compilation, + _compilation: &Compilation, ) -> rspack_core::RuntimeModuleRuntimeRequirements { - let needs_full_hash = self - .get_filename_chunks(compilation) - .into_iter() - .flatten() - .filter_map(|chunk_ukey| { - compilation - .build_chunk_graph_artifact - .chunk_by_ukey - .get(&chunk_ukey) - }) - .filter_map(|chunk| (self.filename_for_chunk)(chunk, compilation)) - .any(|filename| filename.has_hash_placeholder()); - rspack_core::RuntimeModuleRuntimeRequirements { dependencies: { - if needs_full_hash { + if self.needs_full_hash { RuntimeGlobals::GET_FULL_HASH } else { RuntimeGlobals::default() diff --git a/crates/rspack_plugin_runtime/src/runtime_plugin.rs b/crates/rspack_plugin_runtime/src/runtime_plugin.rs index e5dc00ac39e4..492f588f4900 100644 --- a/crates/rspack_plugin_runtime/src/runtime_plugin.rs +++ b/crates/rspack_plugin_runtime/src/runtime_plugin.rs @@ -85,9 +85,42 @@ fn handle_dependency_globals( #[plugin] #[derive(Debug, Default)] -pub struct RuntimePlugin; +pub struct RuntimePlugin { + chunk_filename_has_hash: FxDashMap, +} impl RuntimePlugin { + fn chunk_filename_has_hash(&self, compilation: &Compilation) -> (bool, bool) { + *self + .chunk_filename_has_hash + .entry(compilation.id()) + .or_insert_with(|| { + let output = &compilation.options.output; + let mut javascript = + output.filename.has_hash_placeholder() || output.chunk_filename.has_hash_placeholder(); + let mut css = output.css_filename.has_hash_placeholder() + || output.css_chunk_filename.has_hash_placeholder(); + + for chunk in compilation + .build_chunk_graph_artifact + .chunk_by_ukey + .values() + { + javascript |= chunk + .filename_template() + .is_some_and(|filename| filename.has_hash_placeholder()); + css |= chunk + .css_filename_template() + .is_some_and(|filename| filename.has_hash_placeholder()); + if javascript && css { + break; + } + } + + (javascript, css) + }) + } + pub fn get_compilation_hooks(id: CompilationId) -> ArcRuntimePluginHooks { if !COMPILATION_HOOKS_MAP.contains_key(&id) { COMPILATION_HOOKS_MAP.insert(id, Default::default()); @@ -260,6 +293,7 @@ async fn runtime_requirements_in_tree( ChunkFilenameKind { content_type: "javascript", runtime_module_name: "javascript", + needs_full_hash: self.chunk_filename_has_hash(compilation).0, }, SourceType::JavaScript, runtime_template.render_runtime_globals(&RuntimeGlobals::GET_CHUNK_SCRIPT_FILENAME), @@ -291,6 +325,7 @@ async fn runtime_requirements_in_tree( ChunkFilenameKind { content_type: "css", runtime_module_name: "css", + needs_full_hash: self.chunk_filename_has_hash(compilation).1, }, SourceType::Css, runtime_template.render_runtime_globals(&RuntimeGlobals::GET_CHUNK_CSS_FILENAME), @@ -546,5 +581,6 @@ impl Plugin for RuntimePlugin { fn clear_cache(&self, id: CompilationId) { COMPILATION_HOOKS_MAP.remove(&id); + self.chunk_filename_has_hash.remove(&id); } } diff --git a/tests/rspack-test/RuntimeChunkFilename.test.js b/tests/rspack-test/RuntimeChunkFilename.test.js new file mode 100644 index 000000000000..6254f452c716 --- /dev/null +++ b/tests/rspack-test/RuntimeChunkFilename.test.js @@ -0,0 +1,88 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { rspack } = require("@rspack/core"); + +const fixtureRoot = path.resolve(__dirname, "js/runtime-chunk-filename"); + +function write(root, filename, content) { + const file = path.join(root, filename); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe("chunk filename runtime requirements", () => { + afterAll(() => { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + }); + + it("defines the full-hash runtime for an extracted async CSS filename", async () => { + const root = path.join(fixtureRoot, "extract-css-fullhash"); + fs.rmSync(root, { force: true, recursive: true }); + write( + root, + "src/index.js", + "import(/* webpackChunkName: 'async' */ './async');\n" + ); + write(root, "src/async.js", "import './async.css';\n"); + write(root, "src/async.css", ".async { color: red; }\n"); + + const compiler = rspack({ + context: root, + mode: "development", + target: "web", + devtool: false, + entry: "./src/index.js", + output: { + path: path.join(root, "dist"), + filename: "main.js", + chunkFilename: "[name].js" + }, + module: { + rules: [ + { + test: /\.css$/, + type: "javascript/auto", + use: [rspack.CssExtractRspackPlugin.loader, "css-loader"] + } + ] + }, + optimization: { minimize: false, splitChunks: false }, + plugins: [ + new rspack.CssExtractRspackPlugin({ + filename: "[name].css", + chunkFilename: "[name].[fullhash].css" + }) + ] + }); + + try { + const compilation = await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error); + if (stats.hasErrors()) { + return reject( + new Error(stats.toString({ all: false, errors: true })) + ); + } + resolve(stats.compilation); + }); + }); + const assets = compilation.getAssets(); + const runtime = compilation + .getAsset("main.js") + .source.source() + .toString(); + + expect( + assets.some(({ name }) => /^async\.[a-f0-9]+\.css$/.test(name)) + ).toBe(true); + expect(runtime).toContain("miniCssF"); + expect(runtime).toContain("__webpack_require__.h()"); + expect(runtime).toMatch(/__webpack_require__\.h\s*=/); + } finally { + await new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); + } + }); +}); From f521bccd6fce40b4cf2ab6c48cccbd0abd9bd8a3 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:49:51 +0000 Subject: [PATCH 16/29] fix(watch): scale stale cleanup and recover Copy patterns safely --- crates/rspack_plugin_copy/src/lib.rs | 5 ++ crates/rspack_watcher/src/disk_watcher.rs | 93 +++++++++++++++++++++-- tests/rspack-test/CopyPluginCache.test.js | 49 ++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index 90151dcddc37..ba258f9620a6 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -527,6 +527,7 @@ impl CopyRspackPlugin { "CopyRspackPlugin Error".into(), format!("unable to locate '{glob_query}' glob"), )); + return Ok(None); } let output_path = &compilation.options.output.path; @@ -637,6 +638,10 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { return Ok(Some(cached.results.clone())); } + if cacheable { + self.pattern_cache.remove(&index); + } + let pattern_file_dependencies = FxDashSet::default(); let pattern_context_dependencies = FxDashSet::default(); let pattern_diagnostics = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/rspack_watcher/src/disk_watcher.rs b/crates/rspack_watcher/src/disk_watcher.rs index b8305e3d1d08..16d8cb8911a5 100644 --- a/crates/rspack_watcher/src/disk_watcher.rs +++ b/crates/rspack_watcher/src/disk_watcher.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::Path, sync::Arc, time::Duration}; use notify::{Event, EventKind, RecommendedWatcher, Watcher, event::ModifyKind}; use rspack_paths::ArcPath; @@ -109,10 +109,21 @@ impl DiskWatcher { // notify's inotify backend removes every descendant watch when a recursive // parent is unwatched, so retained children must also be registered again. + let stale_paths: HashSet<&Path> = stale_patterns + .iter() + .map(|(path, _)| path.as_ref()) + .collect(); + let stale_recursive_paths: HashSet<&Path> = stale_patterns + .iter() + .filter_map(|(path, recursive)| recursive.then_some(path.as_ref())) + .collect(); self.watch_patterns.retain(|p| { - !stale_patterns.iter().any(|(path, recursive)| { - p.path == *path || (*recursive && p.path.as_ref().starts_with(path.as_ref())) - }) + !stale_paths.contains(p.path.as_ref()) + && !p + .path + .as_ref() + .ancestors() + .any(|path| stale_recursive_paths.contains(path)) }); for pattern in new_patterns { @@ -260,6 +271,76 @@ mod tests { })); } + #[test] + fn test_many_stale_siblings_keep_retained_children_and_prefix_siblings() { + let root = std::path::PathBuf::from("/virtual-project"); + let recursive_parent = root.join("package-1"); + let retained_child = recursive_parent.join("retained"); + let prefix_sibling = root.join("package-10").join("retained"); + let retained_siblings = (0..1024) + .map(|index| root.join(format!("retained-{index}"))) + .collect::>(); + let stale_siblings = (0..1024) + .map(|index| root.join(format!("stale-{index}"))) + .collect::>(); + + let pattern = |path, mode| WatchPattern { + path: ArcPath::from(path), + mode, + }; + let mut watcher = create_disk_watcher(); + watcher.inner = None; + watcher.watch_patterns = + std::iter::once(pattern(recursive_parent, notify::RecursiveMode::Recursive)) + .chain(std::iter::once(pattern( + retained_child.clone(), + notify::RecursiveMode::NonRecursive, + ))) + .chain(std::iter::once(pattern( + prefix_sibling.clone(), + notify::RecursiveMode::NonRecursive, + ))) + .chain( + retained_siblings + .iter() + .cloned() + .map(|path| pattern(path, notify::RecursiveMode::NonRecursive)), + ) + .chain(stale_siblings.into_iter().enumerate().map(|(index, path)| { + pattern( + path, + if index % 2 == 0 { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }, + ) + })) + .collect(); + + let expected: HashSet = + std::iter::once(pattern(retained_child, notify::RecursiveMode::NonRecursive)) + .chain(std::iter::once(pattern( + prefix_sibling, + notify::RecursiveMode::NonRecursive, + ))) + .chain( + retained_siblings + .into_iter() + .map(|path| pattern(path, notify::RecursiveMode::NonRecursive)), + ) + .collect(); + + watcher + .watch(expected.iter().map(|pattern| WatchPattern { + path: pattern.path.clone(), + mode: pattern.mode, + })) + .unwrap(); + + assert_eq!(watcher.watch_patterns, expected); + } + #[test] fn test_removing_recursive_parent_keeps_retained_child_observable() { let temp_dir = tempfile::TempDir::new().unwrap(); @@ -310,7 +391,7 @@ mod tests { while rx.try_recv().is_ok() {} std::fs::write(&file, "after").unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + Duration::from_secs(10); let observed = loop { if let Ok(events) = rx.try_recv() && events.iter().any(|event| event.path.as_ref() == file) @@ -371,7 +452,7 @@ mod tests { while rx.try_recv().is_ok() {} std::fs::write(grandchild.join("created.txt"), "created").unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + Duration::from_secs(10); let observed = loop { if let Ok(events) = rx.try_recv() && events.iter().any(|event| event.path.as_ref() == parent) diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js index d5d35f6e3b2c..fa411d74db89 100644 --- a/tests/rspack-test/CopyPluginCache.test.js +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -71,6 +71,19 @@ function rebuild(compiler, modifiedFiles = [], removedFiles = []) { }); } +function rebuildAllowErrors(compiler, modifiedFiles = [], removedFiles = []) { + return new Promise((resolve, reject) => { + compiler.__internal__rebuild( + new Set(modifiedFiles), + new Set(removedFiles), + error => { + if (error) return reject(error); + resolve(compiler._lastCompilation); + } + ); + }); +} + function watch(compiler) { const builds = []; const waiters = []; @@ -335,4 +348,40 @@ describe("CopyRspackPlugin pattern cache", () => { await close(compiler); } }); + + it("evicts a cached pattern before an errored recomputation and recovers when the source returns", async () => { + const { root, compiler } = createCompiler("diagnostic-recovery", [ + { from: "assets/source/*.txt", to: "copied", toType: "dir" } + ]); + const source = write(root, "assets/source/one.txt", "before\n"); + + try { + const initial = await compile(compiler); + expect(asset(initial, "copied/assets/source/one.txt")).toBe("before\n"); + + remove(root, "assets/source/one.txt"); + let failed = await rebuildAllowErrors(compiler, [], [source]); + expect(failed.errors.length).toBeGreaterThan(0); + expect( + failed.errors.filter(error => + String(error.message ?? error).includes("unable to locate") + ) + ).toHaveLength(1); + expect(reusedPatterns(failed)).toBe(0); + expect(asset(failed, "copied/assets/source/one.txt")).toBeUndefined(); + + const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); + failed = await rebuildAllowErrors(compiler, [entry]); + expect(failed.errors.length).toBeGreaterThan(0); + expect(reusedPatterns(failed)).toBe(0); + expect(asset(failed, "copied/assets/source/one.txt")).toBeUndefined(); + + write(root, "assets/source/one.txt", "after\n"); + const recovered = await rebuild(compiler, [source]); + expect(asset(recovered, "copied/assets/source/one.txt")).toBe("after\n"); + expect(reusedPatterns(recovered)).toBe(0); + } finally { + await close(compiler); + } + }); }); From 6f0d4e3eb9c9fa343d8f974cfee62228a57bef74 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:49:56 +0000 Subject: [PATCH 17/29] fix(lazy): route compiler prefixes and watch deltas precisely --- packages/rspack/src/Watching.ts | 45 ++++---- .../lazy-compilation/middleware.ts | 4 +- .../rspack-test/LazyCompilationPrefix.test.js | 104 ++++++++++++++++++ 3 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 tests/rspack-test/LazyCompilationPrefix.test.js diff --git a/packages/rspack/src/Watching.ts b/packages/rspack/src/Watching.ts index c931db3a41e2..de287c221b61 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -17,6 +17,13 @@ import type { FileSystemInfoEntry, Watcher } from './util/fs'; type PendingWatchDelta = { added: Set; removed: Set }; +function withWatchDelta( + dependencies: Iterable, + delta: PendingWatchDelta, +): Set & PendingWatchDelta { + return Object.assign(new Set(dependencies), delta); +} + // Merge an incremental `(added, removed)` delta into an accumulator, cancelling // a path that is added then removed (or vice-versa) across calls. function foldWatchDelta( @@ -477,32 +484,18 @@ export class Watching { const pending = this.#pendingWatchDeps!; this.#pendingWatchDeps = undefined; - const fileDependencies = new Set([ - ...compilation.fileDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - fileDependencies.added = pending.file.added; - fileDependencies.removed = pending.file.removed; - - const contextDependencies = new Set([ - ...compilation.contextDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - contextDependencies.added = pending.context.added; - contextDependencies.removed = pending.context.removed; - - const missingDependencies = new Set([ - ...compilation.missingDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - missingDependencies.added = pending.missing.added; - missingDependencies.removed = pending.missing.removed; + const fileDependencies = withWatchDelta( + compilation.fileDependencies, + pending.file, + ); + const contextDependencies = withWatchDelta( + compilation.contextDependencies, + pending.context, + ); + const missingDependencies = withWatchDelta( + compilation.missingDependencies, + pending.missing, + ); this.handler(null, stats); diff --git a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts index bcf68e4b9096..78e699c839aa 100644 --- a/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts +++ b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts @@ -80,7 +80,9 @@ export const lazyCompilationMiddleware = ( const keys = [...middlewareByCompiler.keys()]; return (req: IncomingMessage, res: ServerResponse, next: () => void) => { - const key = keys.find((key) => req.url?.startsWith(key)); + const key = keys.find( + (key) => req.url === key || req.url?.startsWith(`${key}?`), + ); if (!key) { return next?.(); } diff --git a/tests/rspack-test/LazyCompilationPrefix.test.js b/tests/rspack-test/LazyCompilationPrefix.test.js new file mode 100644 index 000000000000..2d7dec58c208 --- /dev/null +++ b/tests/rspack-test/LazyCompilationPrefix.test.js @@ -0,0 +1,104 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { rspack, lazyCompilationMiddleware } = require("@rspack/core"); + +describe("lazy MultiCompiler prefix routing", () => { + it("activates compiler 10 without matching the compiler 1 prefix", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rspack-lazy-prefix-")); + const names = Array.from({ length: 11 }, (_, index) => `compiler-${index}`); + for (const name of names) { + fs.writeFileSync( + path.join(root, `${name}.js`), + `export const name = '${name}';\n` + ); + } + + const builds = []; + const waiters = []; + const nextBuild = () => + builds.length > 0 + ? Promise.resolve(builds.shift()) + : new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for a lazy rebuild")), + 10000 + ); + waiters.push(value => { + clearTimeout(timeout); + resolve(value); + }); + }); + const compiler = rspack( + names.map(name => ({ + context: root, + mode: "development", + name, + target: "web", + devtool: false, + entry: `./${name}.js`, + lazyCompilation: { entries: true, imports: false }, + output: { + path: path.join(root, name), + filename: "main.js", + chunkFilename: "[name].js" + } + })) + ); + const middleware = lazyCompilationMiddleware(compiler); + const watching = compiler.watch({}, (error, stats) => { + const value = { + error: + error ?? + (stats?.hasErrors() + ? new Error(stats.toString({ all: false, errors: true })) + : undefined), + stats + }; + (waiters.shift() ?? (build => builds.push(build)))(value); + }); + + try { + const initial = await nextBuild(); + expect(initial.error).toBeUndefined(); + const bundle = fs.readFileSync( + path.join(root, "compiler-10", "main.js"), + "utf8" + ); + const encoded = bundle.match(/var data = ("(?:[^"\\]|\\.)*")/)?.[1]; + expect(encoded).toBeDefined(); + const moduleId = JSON.parse(encoded); + + await new Promise((resolve, reject) => { + middleware( + { + body: [moduleId], + method: "POST", + url: "/_rspack/lazy/trigger__10?source=test" + }, + { + end: resolve, + write() {}, + writeHead(status) { + expect(status).toBe(200); + } + }, + reject + ).catch(reject); + }); + const updated = await nextBuild(); + expect(updated.error).toBeUndefined(); + expect(updated.stats.stats.map(child => child.compilation.name)).toEqual([ + "compiler-10" + ]); + expect(updated.stats.stats[0].compilation.watchInvalidationKind).toBe( + "lazy" + ); + } finally { + await new Promise((resolve, reject) => + watching.close(error => (error ? reject(error) : resolve())) + ); + fs.rmSync(root, { force: true, recursive: true }); + } + }); +}); From 17d545c75515fa9079755d3ce9ba90c5c11acc7c Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:20:28 +0000 Subject: [PATCH 18/29] fix(watch): coalesce in-flight lazy and file generations --- packages/rspack/src/MultiCompiler.ts | 7 ++- packages/rspack/src/Watching.ts | 46 +++++++++++++--- .../rspack/src/node/NodeWatchFileSystem.ts | 19 ++++++- packages/rspack/src/util/fs.ts | 1 + .../LazyCompilationArtifactChain.test.js | 44 ++++++++++++++-- tests/rspack-test/NodeWatcherGetInfo.test.js | 52 +++++++++++++++++++ .../multiCompilerCases/invalidate.js | 6 ++- 7 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 tests/rspack-test/NodeWatcherGetInfo.test.js diff --git a/packages/rspack/src/MultiCompiler.ts b/packages/rspack/src/MultiCompiler.ts index 17ac3f4e0fc1..ba85b042e3ec 100644 --- a/packages/rspack/src/MultiCompiler.ts +++ b/packages/rspack/src/MultiCompiler.ts @@ -431,7 +431,12 @@ export class MultiCompiler { if (node.state === 'done') { node.state = 'pending'; } else if (node.state === 'running') { - node.state = 'running-outdated'; + // Watching will coalesce its own in-flight invalidation before + // delivering `done`; keep the node runnable so that generation can + // finish and unblock its already-invalidated dependents. + if (!node.compiler.watching?.invalid) { + node.state = 'running-outdated'; + } } for (const child of node.children) { nodeInvalidFromParent(child); diff --git a/packages/rspack/src/Watching.ts b/packages/rspack/src/Watching.ts index de287c221b61..ababdb28987c 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -153,6 +153,12 @@ export class Watching { }, (fileName, changeTime) => { this.#recordInvalidation('normal'); + if (this.running) { + // The aggregate callback can arrive after an in-flight compilation + // finishes. Suppress that stale generation and drain the paused + // watcher before starting the coalesced rebuild. + this.invalid = true; + } if (!this.#invalidReported) { this.#invalidReported = true; this.compiler.hooks.invalid.call(fileName, changeTime); @@ -294,6 +300,7 @@ export class Watching { if (this.suspended || (this.isBlocked() && (this.blocked = true))) { return; } + this.blocked = false; if (this.running) { this.invalid = true; @@ -339,14 +346,7 @@ export class Watching { this.compiler.fileTimestamps = fileTimeInfoEntries; this.compiler.contextTimestamps = contextTimeInfoEntries; } else if (this.pausedWatcher) { - const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } = - this.pausedWatcher.getInfo(); - if (changes.size > 0 || removals.size > 0) { - this.#recordInvalidation('normal'); - } - this.#mergeWithCollected(changes, removals); - this.compiler.fileTimestamps = fileTimeInfoEntries; - this.compiler.contextTimestamps = contextTimeInfoEntries; + this.#drainPausedWatcher(); } this.compiler.__internal__watchInvalidationKind = @@ -451,6 +451,21 @@ export class Watching { stats = new Stats(compilation); + const watcherStartTime = Date.now(); + if ( + !this.invalid && + this.pausedWatcher?.hasPendingEvents?.() !== false && + this.#drainPausedWatcher() + ) { + // Watchpack continues collecting while paused but does not deliver an + // invalid callback. Coalesce those changes before publishing the stale + // generation and advance the baseline so they are not replayed. + this.lastWatcherStartTime = watcherStartTime; + this.invalid = true; + this.#notifyInvalid(); + this.onInvalid(); + } + if ( this.invalid && !this.suspended && @@ -513,6 +528,21 @@ export class Watching { }); } + #drainPausedWatcher() { + if (!this.pausedWatcher) return false; + + const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } = + this.pausedWatcher.getInfo(); + const hasChanges = changes.size > 0 || removals.size > 0; + if (hasChanges) { + this.#recordInvalidation('normal'); + } + this.#mergeWithCollected(changes, removals); + this.compiler.fileTimestamps = fileTimeInfoEntries; + this.compiler.contextTimestamps = contextTimeInfoEntries; + return hasChanges; + } + #mergeWithCollected( changedFiles?: ReadonlySet, removedFiles?: ReadonlySet, diff --git a/packages/rspack/src/node/NodeWatchFileSystem.ts b/packages/rspack/src/node/NodeWatchFileSystem.ts index cbfd4df40b99..735fd335b036 100644 --- a/packages/rspack/src/node/NodeWatchFileSystem.ts +++ b/packages/rspack/src/node/NodeWatchFileSystem.ts @@ -189,9 +189,24 @@ export default class NodeWatchFileSystem implements WatchFileSystem { "Watcher.getContextTimeInfoEntries is deprecated in favor of Watcher.getInfo since that's more performant.", 'DEP_WEBPACK_WATCHER_CONTEXT_TIME_INFO_ENTRIES', ), + hasPendingEvents: () => { + const watcher = this.watcher; + return Boolean( + watcher && + (watcher.aggregatedChanges.size > 0 || + watcher.aggregatedRemovals.size > 0), + ); + }, getInfo: () => { - const removals = this.watcher?.aggregatedRemovals ?? new Set(); - const changes = this.watcher?.aggregatedChanges ?? new Set(); + const watcher = this.watcher; + const { changes, removals } = watcher + ? watcher.paused + ? watcher.getAggregated() + : { + changes: watcher.aggregatedChanges, + removals: watcher.aggregatedRemovals, + } + : { changes: new Set(), removals: new Set() }; if (this.inputFileSystem?.purge) { const fs = this.inputFileSystem; if (removals) { diff --git a/packages/rspack/src/util/fs.ts b/packages/rspack/src/util/fs.ts index feacb25f7c8b..1a683f371a36 100644 --- a/packages/rspack/src/util/fs.ts +++ b/packages/rspack/src/util/fs.ts @@ -19,6 +19,7 @@ export interface Watcher { getAggregatedRemovals?(): Set; // get current aggregated removals that have not yet send to callback getFileTimeInfoEntries?(): Map; // get info about files getContextTimeInfoEntries?(): Map; // get info about directories + hasPendingEvents?(): boolean; // cheaply checks for changes collected while paused getInfo(): WatcherInfo; // get info about timestamps and changes } diff --git a/tests/rspack-test/LazyCompilationArtifactChain.test.js b/tests/rspack-test/LazyCompilationArtifactChain.test.js index 2043126993c1..9772efdaac53 100644 --- a/tests/rspack-test/LazyCompilationArtifactChain.test.js +++ b/tests/rspack-test/LazyCompilationArtifactChain.test.js @@ -31,13 +31,14 @@ async function withTimeout(promise, description) { } describe("lazy MultiCompiler artifact provenance", () => { - it("keeps dependent and coalesced file-backed generations normal", async () => { + async function runArtifactChain(_watcherName, nativeWatcher) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-lazy-chain-")); const sourceFile = path.join(root, "source.js"); const coalescedEntry = path.join(root, "coalesced-source.js"); const stampFile = name => path.join(root, name, "stamp.txt"); const generations = Object.fromEntries(names.map(name => [name, 0])); const events = []; + const invalidations = []; let delayedSource; let resolveFileInvalidation; let server; @@ -56,6 +57,14 @@ describe("lazy MultiCompiler artifact provenance", () => { coalescedEntry, 'export const coalesced = "coalesced";\n' ); + // Keep Watchpack's initial scan from treating fresh fixture files as edits. + const beforeWatch = new Date(Date.now() - 3000); + await Promise.all( + [ + ...names.map(name => path.join(root, `${name}.js`)), + coalescedEntry + ].map(file => fs.utimes(file, beforeWatch, beforeWatch)) + ); const config = name => ({ context: root, @@ -65,7 +74,7 @@ describe("lazy MultiCompiler artifact provenance", () => { name === "source" ? { main: "./source.js", coalesced: "./coalesced-source.js" } : { main: `./${name}.js` }, - experiments: { nativeWatcher: true }, + experiments: { nativeWatcher }, lazyCompilation: { entries: true, imports: false }, mode: "development", name, @@ -78,6 +87,7 @@ describe("lazy MultiCompiler artifact provenance", () => { { apply(compiler) { compiler.hooks.invalid.tap("lazy-artifact-chain", file => { + invalidations.push({ name, file }); if (name === "source" && file === sourceFile) { resolveFileInvalidation?.(); } @@ -278,19 +288,32 @@ describe("lazy MultiCompiler artifact provenance", () => { const fileInvalidated = new Promise(resolve => { resolveFileInvalidation = resolve; }); - delayedSource = { started: signalSourceStarted, release: sourceRelease }; + delayedSource = { + started: signalSourceStarted, + release: sourceRelease + }; const beforeCoalesced = events.length; const beforeGenerations = { ...generations }; assert.equal(await activate("source", JSON.parse(coalescedId)), 200); await withTimeout(sourceStarted, "the coalesced source build to start"); + const beforeFileInvalidations = invalidations.length; await fs.writeFile( sourceFile, 'export const source = "source";\nexport const edited = "file-edit";\n' ); - await withTimeout(fileInvalidated, "the coalesced source file event"); + if (nativeWatcher) { + await withTimeout(fileInvalidated, "the coalesced source file event"); + } releaseSource(); assertBuild(await nextBuild(), ["source", "dependent", "tail"], "normal"); const coalesced = events.slice(beforeCoalesced); + assert.equal( + invalidations + .slice(beforeFileInvalidations) + .filter(event => event.name === "source").length, + 1, + "a coalesced file edit must notify the source compiler exactly once" + ); assert.equal( coalesced.every(event => event.kind === "normal"), true, @@ -319,6 +342,9 @@ describe("lazy MultiCompiler artifact provenance", () => { assert.equal(generations.tail, beforeGenerations.tail + 1); assert.equal(generations.unrelated, beforeGenerations.unrelated); await assertTail(); + const deliveredGenerations = { ...generations }; + await new Promise(resolve => setTimeout(resolve, 80)); + assert.deepEqual(generations, deliveredGenerations); } finally { if (watching) { await new Promise((resolve, reject) => @@ -328,5 +354,13 @@ describe("lazy MultiCompiler artifact provenance", () => { if (server) await new Promise(resolve => server.close(resolve)); await fs.rm(root, { force: true, recursive: true }); } - }); + } + + it.each([ + ["native", true], + ["watchpack", false] + ])( + "keeps dependent and coalesced file-backed generations normal with %s watching", + runArtifactChain + ); }); diff --git a/tests/rspack-test/NodeWatcherGetInfo.test.js b/tests/rspack-test/NodeWatcherGetInfo.test.js new file mode 100644 index 000000000000..a0159f63e423 --- /dev/null +++ b/tests/rspack-test/NodeWatcherGetInfo.test.js @@ -0,0 +1,52 @@ +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { rspack } = require("@rspack/core"); + +describe("NodeWatchFileSystem getInfo", () => { + it("keeps an active aggregate callback and consumes paused changes once", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-watchpack-")); + const entry = path.join(root, "entry.js"); + await fs.writeFile(entry, "export default 1;\n"); + const beforeWatch = new Date(Date.now() - 3000); + await fs.utimes(entry, beforeWatch, beforeWatch); + + const compiler = rspack({ + context: root, + entry, + experiments: { nativeWatcher: false } + }); + const watchFileSystem = compiler.watchFileSystem; + const callbackChanges = []; + const watcher = watchFileSystem.watch( + [entry], + [], + [], + Date.now(), + { aggregateTimeout: 1000 }, + (_error, _fileTimes, _contextTimes, changes) => + callbackChanges.push(changes), + () => {} + ); + + try { + const watchpack = watchFileSystem.watcher; + watchpack._onChange(entry, Date.now(), entry, "change"); + expect(watcher.hasPendingEvents()).toBe(true); + expect(watcher.getInfo().changes).toEqual(new Set([entry])); + expect(watchpack.aggregateTimer).toBeDefined(); + watchpack._onTimeout(); + expect(callbackChanges).toEqual([new Set([entry])]); + + watchpack._onChange(entry, Date.now(), entry, "change"); + expect(watcher.hasPendingEvents()).toBe(true); + expect(watcher.getInfo().changes).toEqual(new Set([entry])); + expect(watcher.getInfo().changes).toEqual(new Set()); + expect(watcher.hasPendingEvents()).toBe(false); + } finally { + watcher.close(); + await new Promise(resolve => compiler.close(resolve)); + await fs.rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/tests/rspack-test/multiCompilerCases/invalidate.js b/tests/rspack-test/multiCompilerCases/invalidate.js index 3af3b0fb30c8..a869718c08da 100644 --- a/tests/rspack-test/multiCompilerCases/invalidate.js +++ b/tests/rspack-test/multiCompilerCases/invalidate.js @@ -80,7 +80,9 @@ module.exports = [ a done, ] `); - expect(lazyCompilationCycles).toEqual([undefined, "lazy"]); + // `a` consumes `b`'s lazy artifact, so its dependent + // generation must remain normal and emit fresh output. + expect(lazyCompilationCycles).toEqual([undefined, "normal"]); events.length = 0; expect(state).toBe(1); state = 2; @@ -110,8 +112,8 @@ module.exports = [ `); expect(lazyCompilationCycles).toEqual([ undefined, - "lazy", "normal", + "normal" ]); state = 3; compiler.close(error => (error ? reject(error) : resolve())); From ee631e7bc88ad882456f760c71611ca0cbe43812 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:42:55 +0000 Subject: [PATCH 19/29] test(wasm): skip unsupported native watcher variants --- tests/rspack-test/CopyPluginCache.test.js | 2 +- tests/rspack-test/LazyCompilationArtifactChain.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js index fa411d74db89..5d55cc26bb02 100644 --- a/tests/rspack-test/CopyPluginCache.test.js +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -160,7 +160,7 @@ describe("CopyRspackPlugin pattern cache", () => { it.each([ ["watchpack", false], - ["native watcher", true] + ...(process.env.WASM ? [] : [["native watcher", true]]) ])( "watches the stable glob base and copies a newly populated sibling with %s", async (watcherName, nativeWatcher) => { diff --git a/tests/rspack-test/LazyCompilationArtifactChain.test.js b/tests/rspack-test/LazyCompilationArtifactChain.test.js index 9772efdaac53..32ba4a67fbb5 100644 --- a/tests/rspack-test/LazyCompilationArtifactChain.test.js +++ b/tests/rspack-test/LazyCompilationArtifactChain.test.js @@ -357,7 +357,7 @@ describe("lazy MultiCompiler artifact provenance", () => { } it.each([ - ["native", true], + ...(process.env.WASM ? [] : [["native", true]]), ["watchpack", false] ])( "keeps dependent and coalesced file-backed generations normal with %s watching", From c73ba1f71f8ec274e7531628ddaaa5111c02541c Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:06:33 +0000 Subject: [PATCH 20/29] perf(copy-plugin): keep ordered pattern emission compact and deterministic --- .../artifacts/build_chunk_graph_artifact.rs | 18 +- crates/rspack_plugin_copy/src/lib.rs | 391 +++++++++--------- .../rspack_plugin_copy/src/pattern_cache.rs | 2 +- tests/rspack-test/CopyPluginCache.test.js | 152 ++++++- 4 files changed, 358 insertions(+), 205 deletions(-) diff --git a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index 4498bcd8a66f..fcb2fb69c585 100644 --- a/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs +++ b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs @@ -32,7 +32,7 @@ pub struct BuildChunkGraphArtifact { pub(crate) code_splitter: CodeSplitter, pub module_idx: IdentifierMap<(u32, u32)>, global_include_modules: Vec, - entry_include_modules: FxIndexMap>, + entry_include_modules: Vec<(String, Vec)>, } impl BuildChunkGraphArtifact { @@ -230,7 +230,7 @@ impl BuildChunkGraphArtifact { .copied() .map(DependenciesBlockIdentifier::AsyncDependenciesBlock), ) { - let outgoings = prepared_connections_by_block + let mut outgoings = prepared_connections_by_block .remove(&block) .unwrap_or_default() .into_iter() @@ -248,7 +248,7 @@ impl BuildChunkGraphArtifact { .is_not_false() }) .map(|connection| connection.module) - .collect::>(); + .peekable(); let mut previous_modules = IdentifierIndexMap::default(); let mut miss_in_previous = true; @@ -273,7 +273,7 @@ impl BuildChunkGraphArtifact { if miss_in_previous && !(matches!(block, DependenciesBlockIdentifier::Module(_)) - && outgoings.is_empty() + && outgoings.peek().is_none() && self.chunk_graph.try_get_module_chunks(&module).is_some()) { logger.log("new module detected, rebuilding chunk graph"); @@ -324,10 +324,7 @@ impl BuildChunkGraphArtifact { fn collect_entry_include_modules( compilation: &Compilation, -) -> ( - Vec, - FxIndexMap>, -) { +) -> (Vec, Vec<(String, Vec)>) { let module_graph = compilation.get_module_graph(); let mut global_includes = compilation .global_entry @@ -440,9 +437,10 @@ fn refresh_entrypoint_options(compilation: &mut Compilation) { let ChunkGroupKind::Entrypoint { options, .. } = &mut entrypoint.kind else { unreachable!("cached entrypoint should have entrypoint options"); }; - **options = entry.options.clone(); + options.filename.clone_from(&entry.options.filename); + options.public_path.clone_from(&entry.options.public_path); - let filename = entry.options.filename.clone(); + let filename = options.filename.clone(); requires_full_chunk_assets |= filename .as_ref() .is_some_and(Filename::has_hash_placeholder); diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index ba258f9620a6..015a6b618257 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -10,7 +10,7 @@ use std::{ use cow_utils::CowUtils; use derive_more::Debug; use fast_glob::glob_match; -use futures::future::{BoxFuture, join_all}; +use futures::{StreamExt, future::BoxFuture, stream::FuturesOrdered}; use regex::Regex; use rspack_core::{ AssetInfo, AssetInfoRelated, Compilation, CompilationAsset, CompilationLogger, @@ -23,7 +23,7 @@ use rspack_error::{Diagnostic, Error, Result}; use rspack_hash::{HashDigest, HashFunction, HashSalt, RspackHashDigest, RspackHasher}; use rspack_hook::{plugin, plugin_hook}; use rspack_paths::{Utf8Path, Utf8PathBuf}; -use rspack_util::fx_hash::{FxDashMap, FxDashSet}; +use rspack_util::fx_hash::FxDashSet; use sugar_path::SugarPath; mod pattern_cache; @@ -124,15 +124,22 @@ pub struct RunPatternResult { pub source: BoxSource, pub info: Option, pub force: bool, - pub priority: i32, - pub pattern_index: usize, } #[plugin] #[derive(Debug)] pub struct CopyRspackPlugin { pub patterns: Vec, - pattern_cache: FxDashMap, + pattern_cache: Mutex>>, +} + +struct PendingPattern<'a> { + index: usize, + pattern: &'a CopyPattern, + cacheable: bool, + file_dependencies: FxDashSet, + context_dependencies: FxDashSet, + diagnostics: Arc>>, } static TEMPLATE_RE: LazyLock = @@ -148,7 +155,8 @@ fn normalize_glob_path_separators(path: &str) -> Cow<'_, str> { impl CopyRspackPlugin { pub fn new(patterns: Vec) -> Self { - Self::new_inner(patterns, Default::default()) + let pattern_cache = Mutex::new(vec![None; patterns.len()]); + Self::new_inner(patterns, pattern_cache) } fn is_cacheable(pattern: &CopyPattern) -> bool { @@ -181,20 +189,11 @@ impl CopyRspackPlugin { diagnostics: Arc>>, compilation: &Compilation, logger: &CompilationLogger, - pattern_index: usize, ) -> Result> { // Exclude directories if entry.is_dir() { return Ok(None); } - if let Some(ignore) = &pattern.glob_options.ignore - && ignore - .iter() - .any(|ignore| glob_match(ignore.as_bytes(), entry.as_str().as_bytes())) - { - return Ok(None); - } - let from = entry; logger.debug(format!("found '{from}'")); @@ -313,11 +312,9 @@ impl CopyRspackPlugin { } }; - let mut source = RawBufferSource::from(source_vec.clone()).boxed(); - - if let Some(transformer) = &pattern.transform_fn { + let source = if let Some(transformer) = &pattern.transform_fn { + let mut source = RawBufferSource::from(source_vec.clone()).boxed(); logger.debug(format!("transforming content for '{absolute_filename}'...")); - // TODO: support cache in the future. handle_transform( transformer, source_vec, @@ -325,8 +322,11 @@ impl CopyRspackPlugin { &mut source, diagnostics, ) - .await - } + .await; + source + } else { + RawBufferSource::from(source_vec).boxed() + }; let filename = if matches!(&to_type, ToType::Template) { logger.log(format!( @@ -356,7 +356,8 @@ impl CopyRspackPlugin { template_str } else { - filename.as_str().normalize().to_string_lossy().to_string() + let filename = filename.as_str().normalize().to_string_lossy().to_string(); + normalize_glob_path_separators(&filename).into_owned() }; Ok(Some(RunPatternResult { @@ -366,20 +367,17 @@ impl CopyRspackPlugin { source, info: pattern.info.clone(), force: pattern.force, - priority: pattern.priority, - pattern_index, })) } - async fn run_patter( + async fn run_pattern( compilation: &Compilation, pattern: &CopyPattern, - index: usize, file_dependencies: &FxDashSet, context_dependencies: &FxDashSet, diagnostics: Arc>>, logger: &CompilationLogger, - ) -> Result>>> { + ) -> Result>> { let orig_from = &pattern.from; let normalized_orig_from = Utf8PathBuf::from(orig_from); @@ -497,20 +495,14 @@ impl CopyRspackPlugin { .await; match glob_entries { - Ok(entries) => { - let entries: Vec<_> = entries - .into_iter() - .filter(|entry| { - if let Some(filters) = &pattern.glob_options.ignore { - // If filters length is 0, exist is true by default - filters - .iter() - .all(|filter| !glob_match(filter.as_bytes(), entry.as_str().as_bytes())) - } else { - true - } + Ok(mut entries) => { + entries.retain(|entry| { + pattern.glob_options.ignore.as_ref().is_none_or(|filters| { + filters + .iter() + .all(|filter| !glob_match(filter.as_bytes(), entry.as_str().as_bytes())) }) - .collect(); + }); if entries.is_empty() { if pattern.no_error_on_missing { @@ -532,7 +524,7 @@ impl CopyRspackPlugin { let output_path = &compilation.options.output.path; - let copied_result = join_all(entries.into_iter().map(|entry| async { + let copied_result = FuturesOrdered::from_iter(entries.into_iter().map(|entry| { Self::analyze_every_entry( entry, pattern, @@ -543,10 +535,9 @@ impl CopyRspackPlugin { diagnostics.clone(), compilation, logger, - index, ) - .await })) + .collect::>() .await .into_iter() .collect::>>()?; @@ -567,7 +558,7 @@ impl CopyRspackPlugin { return Ok(None); } - Ok(Some(copied_result)) + Ok(Some(copied_result.into_iter().flatten().collect())) } Err(e) => { if pattern.no_error_on_missing { @@ -599,116 +590,180 @@ impl CopyRspackPlugin { } } } -} -#[plugin_hook(CompilationProcessAssets for CopyRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_ADDITIONAL)] -async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { - let logger = compilation.get_logger("rspack.CopyRspackPlugin"); - let start = logger.time("run pattern"); - let file_dependencies = FxDashSet::default(); - let context_dependencies = FxDashSet::default(); - let diagnostics = Arc::new(Mutex::new(Vec::new())); - - let mut copied_result: Vec<(i32, RunPatternResult)> = - join_all(self.patterns.iter().enumerate().map(|(index, pattern)| { - let compilation = &*compilation; - let logger = &logger; - let file_dependencies = &file_dependencies; - let context_dependencies = &context_dependencies; - let diagnostics = diagnostics.clone(); - async move { - let cacheable = CopyRspackPlugin::is_cacheable(pattern); - if cacheable - && let Some(cached) = self.pattern_cache.get(&index) - && !cached.is_invalidated( - compilation - .modified_files - .iter() - .chain(compilation.removed_files.iter()) - .map(|changed| changed.as_ref()), + fn emit_pattern_results( + &self, + compilation: &mut Compilation, + results_by_pattern: Vec>>, + ) -> Vec<(Utf8PathBuf, Utf8PathBuf)> { + let mut ordered_patterns = results_by_pattern + .into_iter() + .enumerate() + .collect::>(); + ordered_patterns.sort_unstable_by_key(|(index, _)| (self.patterns[*index].priority, *index)); + + let mut permission_copies = Vec::new(); + for (index, results) in ordered_patterns { + let copy_permissions = self.patterns[index].copy_permissions.unwrap_or(false); + for result in results.into_iter().flatten() { + let permission_copy = copy_permissions.then(|| { + ( + result.absolute_filename.clone(), + compilation.options.output.path.join(&result.filename), ) - { - logger.debug(format!("reusing unchanged copy pattern {index}")); - for file in &cached.file_dependencies { - file_dependencies.insert(file.clone()); + }); + + if let Some(exist_asset) = compilation.assets_mut().get_mut(&result.filename) { + if !result.force { + continue; + } + exist_asset.set_source(Some(Arc::new(result.source))); + if let Some(info) = result.info { + set_info(&mut exist_asset.info, info); } - for context in &cached.context_dependencies { - context_dependencies.insert(context.clone()); + exist_asset.info.source_filename = Some(result.source_filename.to_string()); + exist_asset.info.copied = Some(true); + } else { + let mut asset_info = AssetInfo { + source_filename: Some(result.source_filename.to_string()), + copied: Some(true), + ..Default::default() + }; + + if let Some(info) = result.info { + set_info(&mut asset_info, info); } - return Ok(Some(cached.results.clone())); + + compilation.emit_asset( + result.filename, + CompilationAsset::new(Some(Arc::new(result.source)), asset_info), + ); } - if cacheable { - self.pattern_cache.remove(&index); + if let Some(permission_copy) = permission_copy { + permission_copies.push(permission_copy); } + } + } - let pattern_file_dependencies = FxDashSet::default(); - let pattern_context_dependencies = FxDashSet::default(); - let pattern_diagnostics = Arc::new(Mutex::new(Vec::new())); - let results = CopyRspackPlugin::run_patter( - compilation, - pattern, - index, - &pattern_file_dependencies, - &pattern_context_dependencies, - pattern_diagnostics.clone(), - logger, + permission_copies + } +} + +#[plugin_hook(CompilationProcessAssets for CopyRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_ADDITIONAL)] +async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { + let logger = compilation.get_logger("rspack.CopyRspackPlugin"); + let start = logger.time("run pattern"); + let mut file_dependencies = Vec::new(); + let mut context_dependencies = Vec::new(); + let mut diagnostics = Vec::new(); + let cache_counter = logger.cache("copy pattern cache"); + + let mut results_by_pattern = vec![None; self.patterns.len()]; + let mut pending_patterns = Vec::new(); + { + let mut pattern_cache = self + .pattern_cache + .lock() + .expect("failed to obtain lock of `pattern_cache`"); + pattern_cache.resize_with(self.patterns.len(), || None); + + for (index, pattern) in self.patterns.iter().enumerate() { + let cacheable = CopyRspackPlugin::is_cacheable(pattern); + let cached = &mut pattern_cache[index]; + + if cacheable + && let Some(cached) = cached.as_ref() + && !cached.is_invalidated( + compilation + .modified_files + .iter() + .chain(compilation.removed_files.iter()) + .map(|changed| changed.as_ref()), ) - .await?; + { + cache_counter.hit(); + file_dependencies.extend(cached.file_dependencies.iter().cloned()); + context_dependencies.extend(cached.context_dependencies.iter().cloned()); + results_by_pattern[index] = Some(cached.results.clone()); + continue; + } - let pattern_file_dependencies = pattern_file_dependencies.into_iter().collect::>(); - let pattern_context_dependencies = - pattern_context_dependencies.into_iter().collect::>(); - let pattern_diagnostics = std::mem::take( - pattern_diagnostics - .lock() - .expect("failed to obtain lock of `pattern_diagnostics`") - .deref_mut(), - ); - let has_diagnostics = !pattern_diagnostics.is_empty(); - for file in &pattern_file_dependencies { - file_dependencies.insert(file.clone()); - } - for context in &pattern_context_dependencies { - context_dependencies.insert(context.clone()); + cache_counter.miss(); + *cached = None; + pending_patterns.push(PendingPattern { + index, + pattern, + cacheable, + file_dependencies: FxDashSet::default(), + context_dependencies: FxDashSet::default(), + diagnostics: Arc::new(Mutex::new(Vec::new())), + }); + } + } + logger.cache_end(cache_counter); + let pending_results = FuturesOrdered::from_iter(pending_patterns.iter().map(|pending| { + CopyRspackPlugin::run_pattern( + compilation, + pending.pattern, + &pending.file_dependencies, + &pending.context_dependencies, + pending.diagnostics.clone(), + &logger, + ) + })) + .collect::>() + .await; + + let mut first_error = None; + if !pending_patterns.is_empty() { + let mut pattern_cache = self + .pattern_cache + .lock() + .expect("failed to obtain lock of `pattern_cache`"); + for (pending, results) in pending_patterns.into_iter().zip(pending_results) { + let results = match results { + Ok(results) => results, + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + continue; } - diagnostics + }; + let pattern_file_dependencies = pending.file_dependencies.into_iter().collect::>(); + let pattern_context_dependencies = + pending.context_dependencies.into_iter().collect::>(); + let pattern_diagnostics = std::mem::take( + pending + .diagnostics .lock() - .expect("failed to obtain lock of `diagnostics`") - .extend(pattern_diagnostics); - - if cacheable - && !has_diagnostics - && let Some(results) = results.as_ref() - { - self.pattern_cache.insert( - index, - CachedPatternResult { - results: results.clone(), - file_dependencies: pattern_file_dependencies, - context_dependencies: pattern_context_dependencies, - }, - ); - } else { - self.pattern_cache.remove(&index); - } - - Ok(results) + .expect("failed to obtain lock of `pattern_diagnostics`") + .deref_mut(), + ); + let has_diagnostics = !pattern_diagnostics.is_empty(); + file_dependencies.extend(pattern_file_dependencies.iter().cloned()); + context_dependencies.extend(pattern_context_dependencies.iter().cloned()); + diagnostics.extend(pattern_diagnostics); + + if pending.cacheable + && !has_diagnostics + && let Some(results) = results.as_ref() + { + pattern_cache[pending.index] = Some(CachedPatternResult { + results: results.clone(), + file_dependencies: pattern_file_dependencies, + context_dependencies: pattern_context_dependencies, + }); } - })) - .await - .into_iter() - .collect::>>()? - .into_iter() - .flatten() - .flat_map(|item| { - item - .into_iter() - .flatten() - .map(|item| (item.priority, item)) - .collect::>() - }) - .collect(); + + results_by_pattern[pending.index] = results; + } + } + if let Some(error) = first_error { + return Err(error); + } + logger.time_end(start); let start = logger.time("emit assets"); @@ -718,60 +773,14 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { compilation .context_dependencies .extend(context_dependencies.into_iter().map(Into::into)); - compilation.extend_diagnostics(std::mem::take( - diagnostics - .lock() - .expect("failed to obtain lock of `diagnostics`") - .deref_mut(), - )); - - copied_result.sort_unstable_by_key(|a| a.0); - - // Keep track of source to destination file mappings for permission copying - let mut permission_copies = Vec::new(); + compilation.extend_diagnostics(diagnostics); - copied_result.into_iter().for_each(|(_priority, result)| { - let source_path = result.absolute_filename.clone(); - let dest_path = compilation.options.output.path.join(&result.filename); - - if let Some(exist_asset) = compilation.assets_mut().get_mut(&result.filename) { - if !result.force { - return; - } - exist_asset.set_source(Some(Arc::new(result.source))); - if let Some(info) = result.info { - set_info(&mut exist_asset.info, info); - } - exist_asset.info.source_filename = Some(result.source_filename.to_string()); - exist_asset.info.copied = Some(true); - } else { - let mut asset_info = AssetInfo { - source_filename: Some(result.source_filename.to_string()), - copied: Some(true), - ..Default::default() - }; - - if let Some(info) = result.info { - set_info(&mut asset_info, info); - } - - compilation.emit_asset( - result.filename, - CompilationAsset::new(Some(Arc::new(result.source)), asset_info), - ); - } - - // Store the paths for permission copying along with the pattern index - permission_copies.push((result.pattern_index, source_path, dest_path)); - }); + let permission_copies = self.emit_pattern_results(compilation, results_by_pattern); logger.time_end(start); // Handle permission copying after all assets are emitted - for (pattern_index, source_path, dest_path) in permission_copies.iter() { - if let Some(pattern) = self.patterns.get(*pattern_index) - && pattern.copy_permissions.unwrap_or(false) - && let Ok(Some(permissions)) = compilation.input_filesystem.permissions(source_path).await - { + for (source_path, dest_path) in permission_copies.iter() { + if let Ok(Some(permissions)) = compilation.input_filesystem.permissions(source_path).await { // Make sure the output directory exists if let Some(parent) = dest_path.parent() { compilation diff --git a/crates/rspack_plugin_copy/src/pattern_cache.rs b/crates/rspack_plugin_copy/src/pattern_cache.rs index 8cf4ff36c7ae..06a50216c52f 100644 --- a/crates/rspack_plugin_copy/src/pattern_cache.rs +++ b/crates/rspack_plugin_copy/src/pattern_cache.rs @@ -4,7 +4,7 @@ use crate::RunPatternResult; #[derive(Debug, Clone)] pub(super) struct CachedPatternResult { - pub(super) results: Vec>, + pub(super) results: Vec, pub(super) file_dependencies: Vec, pub(super) context_dependencies: Vec, } diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js index 5d55cc26bb02..f7eb28fedfb7 100644 --- a/tests/rspack-test/CopyPluginCache.test.js +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -140,11 +140,31 @@ function reusedPatterns(compilation) { loggingDebug: [/CopyRspackPlugin/] }).logging; + const entries = Object.values(logging || {}) + .flatMap(group => group.entries || []) + .map(entry => entry.message || ""); + const summary = entries.find(message => + message.startsWith("copy pattern cache: ") + ); + const hits = summary?.match(/\((\d+)\/\d+\)$/); + return hits ? Number(hits[1]) : 0; +} + +function foundPatternFiles(compilation, directory) { + const logging = new Stats(compilation).toJson({ + all: false, + logging: false, + loggingDebug: [/CopyRspackPlugin/] + }).logging; + const normalizedDirectory = directory.replaceAll("\\", "/"); + return Object.values(logging || {}) .flatMap(group => group.entries || []) - .filter(entry => - entry.message?.startsWith("reusing unchanged copy pattern") - ).length; + .map(entry => entry.message || "") + .map(message => message.match(/^found '(.+)'$/)?.[1]) + .filter(filename => + filename?.replaceAll("\\", "/").includes(normalizedDirectory) + ); } async function close(compiler) { @@ -349,6 +369,103 @@ describe("CopyRspackPlugin pattern cache", () => { } }); + it("preserves equal-priority copy order with interleaved cache hits and misses", async () => { + const patternCount = 64; + const patterns = Array.from({ length: patternCount }, (_, index) => ({ + from: `assets/${index}.txt`, + to: "copied/value.txt", + toType: "file", + force: true, + priority: index % 5 + })); + const { root, compiler } = createCompiler("mixed-cache-order", patterns); + const files = patterns.map((_, index) => + write(root, `assets/${index}.txt`, `${index}\n`) + ); + const winner = Math.floor((patternCount - 5) / 5) * 5 + 4; + + try { + const initial = await compile(compiler); + expect(asset(initial, "copied/value.txt")).toBe(`${winner}\n`); + + write(root, "assets/0.txt", "first-edited\n"); + let updated = await rebuild(compiler, [files[0]]); + expect(reusedPatterns(updated)).toBe(patternCount - 1); + expect(asset(updated, "copied/value.txt")).toBe(`${winner}\n`); + + write(root, `assets/${winner}.txt`, "last-edited\n"); + updated = await rebuild(compiler, [files[winner]]); + expect(reusedPatterns(updated)).toBe(patternCount - 1); + expect(asset(updated, "copied/value.txt")).toBe("last-edited\n"); + + const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); + updated = await rebuild(compiler, [entry]); + expect(reusedPatterns(updated)).toBe(patternCount); + expect(asset(updated, "copied/value.txt")).toBe("last-edited\n"); + } finally { + await close(compiler); + } + }); + + it("preserves forced glob emission order with interleaved pattern priorities and cache hits", async () => { + const interleavedCount = 32; + const globFileCount = 64; + const patterns = Array.from({ length: interleavedCount }, (_, index) => ({ + from: `assets/interleaved/${index}.txt`, + to: `copied/interleaved-${index}.txt`, + toType: "file", + force: true, + priority: index % 5 + })); + patterns.splice(Math.floor(interleavedCount / 2), 0, { + from: "assets/many/*.txt", + to: "copied/many.txt", + toType: "file", + force: true, + priority: 2 + }); + const { root, compiler } = createCompiler("forced-glob-order", patterns); + const interleaved = Array.from({ length: interleavedCount }, (_, index) => + write(root, `assets/interleaved/${index}.txt`, `${index}\n`) + ); + const globFiles = Array.from({ length: globFileCount }, (_, index) => + write( + root, + `assets/many/${String(index).padStart(4, "0")}.txt`, + `${index}\n` + ) + ); + const expectedWinner = compilation => { + const found = foundPatternFiles(compilation, "/assets/many/"); + expect(found).toHaveLength(globFileCount); + return fs.readFileSync(found.at(-1), "utf-8"); + }; + + try { + let updated = await compile(compiler); + let winner = expectedWinner(updated); + expect(asset(updated, "copied/many.txt")).toBe(winner); + + write(root, "assets/interleaved/0.txt", "interleaved-edited\n"); + updated = await rebuild(compiler, [interleaved[0]]); + expect(reusedPatterns(updated)).toBe(patterns.length - 1); + expect(asset(updated, "copied/many.txt")).toBe(winner); + + write(root, "assets/many/0000.txt", "glob-edited\n"); + updated = await rebuild(compiler, [globFiles[0]]); + expect(reusedPatterns(updated)).toBe(patterns.length - 1); + winner = expectedWinner(updated); + expect(asset(updated, "copied/many.txt")).toBe(winner); + + const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); + updated = await rebuild(compiler, [entry]); + expect(reusedPatterns(updated)).toBe(patterns.length); + expect(asset(updated, "copied/many.txt")).toBe(winner); + } finally { + await close(compiler); + } + }); + it("evicts a cached pattern before an errored recomputation and recovers when the source returns", async () => { const { root, compiler } = createCompiler("diagnostic-recovery", [ { from: "assets/source/*.txt", to: "copied", toType: "dir" } @@ -384,4 +501,33 @@ describe("CopyRspackPlugin pattern cache", () => { await close(compiler); } }); + + it("keeps a successful sibling cached while a glob recomputation reports an error", async () => { + const { root, compiler } = createCompiler("sibling-diagnostic", [ + { from: "assets/missing/*.txt", to: "copied", toType: "dir" }, + { from: "assets/stable.txt", to: "copied/stable.txt", toType: "file" } + ]); + const missing = write(root, "assets/missing/one.txt", "one\n"); + const stable = write(root, "assets/stable.txt", "before\n"); + + try { + await compile(compiler); + remove(root, "assets/missing/one.txt"); + write(root, "assets/stable.txt", "after\n"); + let failed = await rebuildAllowErrors(compiler, [stable], [missing]); + + expect(failed.errors).toHaveLength(1); + expect(asset(failed, "copied/stable.txt")).toBe("after\n"); + expect(reusedPatterns(failed)).toBe(0); + + const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); + failed = await rebuildAllowErrors(compiler, [entry]); + + expect(failed.errors).toHaveLength(1); + expect(asset(failed, "copied/stable.txt")).toBe("after\n"); + expect(reusedPatterns(failed)).toBe(1); + } finally { + await close(compiler); + } + }); }); From af562a10928a5a77860415f65807ebbc5a4806b3 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:06:38 +0000 Subject: [PATCH 21/29] fix(watcher): serialize native watcher lifecycle safely Co-authored-by: pshu <415655+stormslowly@users.noreply.github.com> --- crates/node_binding/napi-binding.d.ts | 7 - crates/rspack_binding_api/Cargo.toml | 2 +- .../rspack_binding_api/src/native_watcher.rs | 106 +++++++-------- packages/rspack/src/NativeWatchFileSystem.ts | 16 +-- pnpm-lock.yaml | 3 + .../NativeWatcherLifecycle.test.js | 124 ++++++++++++++++++ tests/rspack-test/package.json | 1 + 7 files changed, 184 insertions(+), 75 deletions(-) create mode 100644 tests/rspack-test/NativeWatcherLifecycle.test.js diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index 5bcca3317890..1a1493d24dd2 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -468,13 +468,6 @@ export declare class NativeWatcher { triggerEvent(kind: 'change' | 'remove' | 'create', path: string): void takePendingEvents(): NativeWatchResult acknowledgePendingEvents(generation: number): void - /** - * # Safety - * - * This function is unsafe because it uses `&mut self` to call the watcher asynchronously. - * It's important to ensure that the watcher is not used in any other places before this function is finished. - * You must ensure that the watcher not call watch, close or pause in the same time, otherwise it may lead to undefined behavior. - */ close(): Promise pause(): void } diff --git a/crates/rspack_binding_api/Cargo.toml b/crates/rspack_binding_api/Cargo.toml index 0d7deeb585c6..2fb439f26848 100644 --- a/crates/rspack_binding_api/Cargo.toml +++ b/crates/rspack_binding_api/Cargo.toml @@ -117,7 +117,7 @@ serde = { workspace = true } serde_json = { workspace = true } simd-json = { workspace = true } swc_core = { workspace = true, default-features = false, features = ["ecma_transforms_react"] } -tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "test-util", "tracing", "parking_lot"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "test-util", "tracing", "parking_lot"] } ustr = { workspace = true } [package.metadata.cargo-shear] diff --git a/crates/rspack_binding_api/src/native_watcher.rs b/crates/rspack_binding_api/src/native_watcher.rs index 1d8b03ec9d2f..1a3f4ce6587f 100644 --- a/crates/rspack_binding_api/src/native_watcher.rs +++ b/crates/rspack_binding_api/src/native_watcher.rs @@ -1,16 +1,19 @@ use std::{ boxed::Box, - panic::AssertUnwindSafe, path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use futures::FutureExt; use napi::bindgen_prelude::*; use napi_derive::*; use rspack_paths::ArcPath; use rspack_regex::RspackRegex; use rspack_watcher::{FsEventKind, FsWatcher, FsWatcherIgnored, FsWatcherOptions}; +use tokio::sync::Mutex; type JsWatcherIgnored = Either3, RspackRegex>; @@ -56,10 +59,14 @@ pub struct NativeWatchUndelayedEvent { pub path: String, } +struct NativeWatcherState { + watcher: Mutex, + closed: AtomicBool, +} + #[napi] pub struct NativeWatcher { - watcher: FsWatcher, - closed: bool, + state: Arc, } fn timestamp_to_system_time(millis: u64) -> SystemTime { @@ -80,16 +87,17 @@ impl NativeWatcher { ); Self { - watcher, - closed: false, + state: Arc::new(NativeWatcherState { + watcher: Mutex::new(watcher), + closed: AtomicBool::new(false), + }), } } #[napi] #[allow(clippy::too_many_arguments)] pub fn watch( - &mut self, - reference: Reference, + &self, files: (Vec, Vec), directories: (Vec, Vec), missing: (Vec, Vec), @@ -98,9 +106,8 @@ impl NativeWatcher { callback: Function<'static>, #[napi(ts_arg_type = "(event: NativeWatchUndelayedEvent) => void")] callback_undelayed: Function<'static>, - env: Env, ) -> napi::Result<()> { - if self.closed { + if self.state.closed.load(Ordering::Acquire) { return Err(napi::Error::from_reason( "The native watcher has been closed, cannot watch again.", )); @@ -110,11 +117,12 @@ impl NativeWatcher { let js_event_handler_undelayed = JsEventHandlerUndelayed::new(callback_undelayed)?; let start_time = start_time.get_u64().1; + let state = Arc::clone(&self.state); - reference.share_with(env, |native_watcher| { - rspack_napi::runtime::spawn(async move { - native_watcher - .watcher + rspack_napi::runtime::spawn(async move { + let mut watcher = state.watcher.lock().await; + if !state.closed.load(Ordering::Acquire) { + watcher .watch( to_tuple_path_iterator(files), to_tuple_path_iterator(directories), @@ -123,10 +131,9 @@ impl NativeWatcher { Box::new(js_event_handler), Box::new(js_event_handler_undelayed), ) - .await - }); - Ok(()) - })?; + .await; + } + }); Ok(()) } @@ -140,14 +147,17 @@ impl NativeWatcher { _ => None, } { self + .state .watcher + .blocking_lock() .trigger_event(&ArcPath::from(AsRef::::as_ref(&path)), kind); } } #[napi] pub fn take_pending_events(&self) -> NativeWatchResult { - let (changed_files, removed_files, generation) = self.watcher.take_pending_events(); + let (changed_files, removed_files, generation) = + self.state.watcher.blocking_lock().take_pending_events(); NativeWatchResult { changed_files: changed_files.into_iter().collect(), removed_files: removed_files.into_iter().collect(), @@ -157,55 +167,35 @@ impl NativeWatcher { #[napi] pub fn acknowledge_pending_events(&self, generation: u32) { - self.watcher.acknowledge_pending_events(generation); + self + .state + .watcher + .blocking_lock() + .acknowledge_pending_events(generation); } #[napi(ts_return_type = "Promise")] - /// # Safety - /// - /// This function is unsafe because it uses `&mut self` to call the watcher asynchronously. - /// It's important to ensure that the watcher is not used in any other places before this function is finished. - /// You must ensure that the watcher not call watch, close or pause in the same time, otherwise it may lead to undefined behavior. - pub unsafe fn close<'env>( - &mut self, - env: &'env Env, - reference: Reference, - ) -> napi::Result> { - let (deferred, promise) = env.create_deferred()?; - let mut promise = PromiseRaw::new(env.raw(), promise.raw()); - let shared_reference = reference.share_with(Env::from_raw(env.raw()), |native_watcher| { - rspack_napi::runtime::spawn(async move { - let result = AssertUnwindSafe(async { - native_watcher - .watcher - .close() - .await - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - native_watcher.closed = true; - Ok(()) - }) - .catch_unwind() - .await; - - match result { - Ok(Ok(())) => deferred.resolve(|_| Ok(())), - Ok(Err(error)) => deferred.reject(error), - Err(payload) => deferred.reject(rspack_napi::runtime::panic_to_napi_error(payload)), - } - }); - Ok(()) - })?; - - promise.finally(|_env| { - drop(shared_reference); - Ok(()) + pub fn close<'env>(&self, env: &'env Env) -> napi::Result> { + self.state.closed.store(true, Ordering::Release); + let state = Arc::clone(&self.state); + + rspack_napi::runtime::promise_from_future(env, async move { + state + .watcher + .lock() + .await + .close() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) }) } #[napi] pub fn pause(&self) -> napi::Result<()> { self + .state .watcher + .blocking_lock() .pause() .map_err(|e| napi::Error::from_reason(e.to_string()))?; diff --git a/packages/rspack/src/NativeWatchFileSystem.ts b/packages/rspack/src/NativeWatchFileSystem.ts index 0c85b9a0b63f..995d6f804a7a 100644 --- a/packages/rspack/src/NativeWatchFileSystem.ts +++ b/packages/rspack/src/NativeWatchFileSystem.ts @@ -229,15 +229,13 @@ export default class NativeWatchFileSystem implements WatchFileSystem { return { close: () => { - nativeWatcher.close().then( - () => { - // Clean up the internal reference to the native watcher to allow it to be garbage collected. - this.#inner = undefined; - }, - (err: unknown) => { - console.error('Error closing native watcher:', err); - }, - ); + if (this.#inner === nativeWatcher) { + this.#inner = undefined; + this.#isFirstWatch = true; + } + nativeWatcher.close().catch((err: unknown) => { + console.error('Error closing native watcher:', err); + }); }, pause: () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 135b93aa263e..09b7914736a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,6 +609,9 @@ importers: '@prefresh/utils': specifier: 1.2.1 version: 1.2.1 + '@rspack/binding': + specifier: workspace:* + version: link:../../crates/node_binding '@rspack/binding-testing': specifier: workspace:* version: link:../../crates/rspack_binding_builder_testing diff --git a/tests/rspack-test/NativeWatcherLifecycle.test.js b/tests/rspack-test/NativeWatcherLifecycle.test.js new file mode 100644 index 000000000000..5502a016c75d --- /dev/null +++ b/tests/rspack-test/NativeWatcherLifecycle.test.js @@ -0,0 +1,124 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { NativeWatcher } = require("@rspack/binding"); +const { rspack } = require("@rspack/core"); + +describe("NativeWatcher lifecycle", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rspack-native-lifecycle-")); + const files = Array.from({ length: 40 }, (_, index) => { + const file = path.join(root, `file-${index}.txt`); + fs.writeFileSync(file, `${index}\n`); + return file; + }); + const raceDirectories = Array.from({ length: 20 }, (_, index) => { + const directory = path.join(root, `sub-${index}`); + fs.mkdirSync(directory, { recursive: true }); + return directory; + }); + const raceFiles = Array.from({ length: 400 }, (_, index) => { + const file = path.join( + raceDirectories[index % raceDirectories.length], + `file-${index}.txt` + ); + fs.writeFileSync(file, `${index}\n`); + return file; + }); + const startWatch = (watcher, watchedFiles, directories) => + watcher.watch( + [watchedFiles, []], + [directories, []], + [[], []], + BigInt(Date.now()), + () => {}, + () => {} + ); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("serializes immediate and repeated close with watch and pause", async () => { + for (let index = 0; index < 1000; index++) { + const watcher = new NativeWatcher({ aggregateTimeout: 0 }); + const watch = () => startWatch(watcher, files, [root]); + + watch(); + watch(); + watcher.pause(); + await Promise.all([watcher.close(), watcher.close()]); + + expect(watch).toThrow("The native watcher has been closed"); + } + }); + + it("serializes immediate close with overlapping watches on a large dependency set", async () => { + for (let index = 0; index < 30; index++) { + const watcher = new NativeWatcher({ aggregateTimeout: 1 }); + const watch = () => + startWatch(watcher, raceFiles, [root, ...raceDirectories]); + + for (let count = 0; count < 5; count++) { + watch(); + } + await watcher.close(); + + expect(watch).toThrow("The native watcher has been closed"); + } + }); + + it("recreates a closed native watcher with its complete dependency set", async () => { + const compiler = rspack({ + context: root, + entry: files[0], + experiments: { nativeWatcher: true } + }); + const watchFileSystem = compiler.watchFileSystem; + const dependencies = values => + Object.assign(values, { added: [], removed: [] }); + const watch = callback => + watchFileSystem.watch( + dependencies(files), + dependencies([root]), + dependencies([]), + Date.now(), + { aggregateTimeout: 0 }, + callback, + () => {} + ); + + const firstNativeWatcher = watchFileSystem.getNativeWatcher({}); + const first = watch(() => {}); + first.close(); + + const secondNativeWatcher = watchFileSystem.getNativeWatcher({}); + expect(secondNativeWatcher).not.toBe(firstNativeWatcher); + + let second; + try { + const changes = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("timed out waiting for recreated native watcher")), + 5000 + ); + second = watch((_error, _fileTimes, _contextTimes, changes) => { + clearTimeout(timeout); + resolve(changes); + }); + secondNativeWatcher.acknowledgePendingEvents(0); + fs.writeFileSync(files[0], "changed\n"); + const changedAt = new Date(Date.now() + 2000); + fs.utimesSync(files[0], changedAt, changedAt); + watchFileSystem.triggerEvent("change", files[0]); + }); + + expect([...changes]).toContain(files[0]); + } finally { + second?.close(); + await Promise.all([ + firstNativeWatcher.close(), + secondNativeWatcher.close() + ]); + } + }); +}); diff --git a/tests/rspack-test/package.json b/tests/rspack-test/package.json index 0ef2f89e6044..893367b94cbc 100644 --- a/tests/rspack-test/package.json +++ b/tests/rspack-test/package.json @@ -18,6 +18,7 @@ "@module-federation/runtime-tools": "2.7.0", "@prefresh/core": "1.5.10", "@prefresh/utils": "1.2.1", + "@rspack/binding": "workspace:*", "@rspack/binding-testing": "workspace:*", "@rspack/cli": "workspace:*", "@rspack/core": "workspace:*", From 43a2264be6bf253c9e17219ca1d33df681748ca0 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:06:48 +0000 Subject: [PATCH 22/29] fix(hmr): isolate concurrent runners and dispose transient modules --- .../javascript_hot_module_replacement.ejs | 1 + .../src/helper/util/expectWarningFactory.js | 8 +++- .../rspack-test-tools/src/runner/web/index.ts | 7 ++- .../LazyCompilationArtifactChain.test.js | 19 +++++++- .../WebRunnerConsoleIsolation.test.js | 45 +++++++++++++++++++ .../preserve-disposed-module-factories/a.js | 1 + .../index.js | 12 +++++ 7 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 tests/rspack-test/WebRunnerConsoleIsolation.test.js diff --git a/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs b/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs index 573364008547..4eb3ab53f39f 100644 --- a/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs +++ b/crates/rspack_plugin_runtime/src/runtime_module/runtime/javascript_hot_module_replacement.ejs @@ -290,6 +290,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (<%- HAS_OWN_PROPERTY %>(preservedModuleFactories, moduleId)) { var module = <%- MODULE_CACHE %>[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = <%- MODULE_CACHE %>[module.children[i]]; if (!child) continue; diff --git a/packages/rspack-test-tools/src/helper/util/expectWarningFactory.js b/packages/rspack-test-tools/src/helper/util/expectWarningFactory.js index 87325dde0eb8..da1152e64ce7 100644 --- a/packages/rspack-test-tools/src/helper/util/expectWarningFactory.js +++ b/packages/rspack-test-tools/src/helper/util/expectWarningFactory.js @@ -9,8 +9,12 @@ export function expectWarningFactory() { }); afterEach(() => { - expectWarning(); - console.warn = oldWarn; + try { + expectWarning(); + } finally { + warnings.length = 0; + console.warn = oldWarn; + } }); const expectWarning = (...regexp) => { diff --git a/packages/rspack-test-tools/src/runner/web/index.ts b/packages/rspack-test-tools/src/runner/web/index.ts index 9fc7b4df7343..0c73334354fb 100644 --- a/packages/rspack-test-tools/src/runner/web/index.ts +++ b/packages/rspack-test-tools/src/runner/web/index.ts @@ -31,8 +31,11 @@ export class WebRunner extends NodeRunner { constructor(protected _webOptions: IWebRunnerOptions) { super(_webOptions); + // Hot cases run concurrently and can temporarily replace console methods. + // Keep those overrides local to the JSDOM instance while preserving normal output. + const scopedConsole = Object.create(console) as Console; const virtualConsole = new VirtualConsole({}); - virtualConsole.sendTo(console, { + virtualConsole.sendTo(scopedConsole, { omitJSDOMErrors: true, }); this.dom = new JSDOM( @@ -51,7 +54,7 @@ export class WebRunner extends NodeRunner { }, ); - this.dom.window.console = console; + this.dom.window.console = scopedConsole; // compat with FakeDocument this.dom.window.eval(` var linkSheetDescriptor = Object.getOwnPropertyDescriptor(HTMLLinkElement.prototype, "sheet"); diff --git a/tests/rspack-test/LazyCompilationArtifactChain.test.js b/tests/rspack-test/LazyCompilationArtifactChain.test.js index 32ba4a67fbb5..6d0d6ad73c1d 100644 --- a/tests/rspack-test/LazyCompilationArtifactChain.test.js +++ b/tests/rspack-test/LazyCompilationArtifactChain.test.js @@ -30,6 +30,18 @@ async function withTimeout(promise, description) { } } +async function waitForPausedChange(watching, compilerName) { + const child = watching.watchings.find( + watching => watching.compiler.name === compilerName + ); + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child?.pausedWatcher?.hasPendingEvents?.()) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for the paused ${compilerName} watcher`); +} + describe("lazy MultiCompiler artifact provenance", () => { async function runArtifactChain(_watcherName, nativeWatcher) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "rspack-lazy-chain-")); @@ -40,6 +52,7 @@ describe("lazy MultiCompiler artifact provenance", () => { const events = []; const invalidations = []; let delayedSource; + let releaseSource; let resolveFileInvalidation; let server; let watching; @@ -88,7 +101,7 @@ describe("lazy MultiCompiler artifact provenance", () => { apply(compiler) { compiler.hooks.invalid.tap("lazy-artifact-chain", file => { invalidations.push({ name, file }); - if (name === "source" && file === sourceFile) { + if (name === "source" && path.basename(file ?? "") === "source.js") { resolveFileInvalidation?.(); } }); @@ -278,7 +291,6 @@ describe("lazy MultiCompiler artifact provenance", () => { await assertTail(); let signalSourceStarted; - let releaseSource; const sourceStarted = new Promise(resolve => { signalSourceStarted = resolve; }); @@ -303,6 +315,8 @@ describe("lazy MultiCompiler artifact provenance", () => { ); if (nativeWatcher) { await withTimeout(fileInvalidated, "the coalesced source file event"); + } else { + await waitForPausedChange(watching, "source"); } releaseSource(); assertBuild(await nextBuild(), ["source", "dependent", "tail"], "normal"); @@ -346,6 +360,7 @@ describe("lazy MultiCompiler artifact provenance", () => { await new Promise(resolve => setTimeout(resolve, 80)); assert.deepEqual(generations, deliveredGenerations); } finally { + releaseSource?.(); if (watching) { await new Promise((resolve, reject) => watching.close(error => (error ? reject(error) : resolve())) diff --git a/tests/rspack-test/WebRunnerConsoleIsolation.test.js b/tests/rspack-test/WebRunnerConsoleIsolation.test.js new file mode 100644 index 000000000000..ad600255fbbd --- /dev/null +++ b/tests/rspack-test/WebRunnerConsoleIsolation.test.js @@ -0,0 +1,45 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { WebRunner } = require("@rspack/test-tools"); + +it("keeps concurrent web-runner console overrides isolated", async () => { + const root = path.resolve(__dirname, "js/web-runner-console-isolation"); + const bundle = path.join(root, "bundle.js"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(bundle, "module.exports = console;\n"); + + const createRunner = name => + new WebRunner({ + location: "https://test.cases/path/index.html", + env: { expect, it, beforeEach, afterEach }, + name, + testConfig: {}, + source: root, + dist: root, + compilerOptions: { target: "web", node: false }, + runInNewContext: true + }); + + const originalWarn = console.warn; + try { + const first = await createRunner("first").run(bundle); + const second = await createRunner("second").run(bundle); + const firstWarnings = []; + const secondWarnings = []; + first.warn = warning => firstWarnings.push(warning); + second.warn = warning => secondWarnings.push(warning); + + first.warn("first warning"); + second.warn("second warning"); + + expect(first).not.toBe(console); + expect(second).not.toBe(console); + expect(first).not.toBe(second); + expect(firstWarnings).toEqual(["first warning"]); + expect(secondWarnings).toEqual(["second warning"]); + expect(console.warn).toBe(originalWarn); + } finally { + console.warn = originalWarn; + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js index a1219a851ba1..9676f7e853b6 100644 --- a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js @@ -2,3 +2,4 @@ import { getParents } from "./child"; getParents(); export default "a"; +export const readChildParents = () => require("./child").getParents(); diff --git a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js index d682c17386f1..1ce7004637fe 100644 --- a/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js @@ -6,12 +6,14 @@ let factoryDuringApply; let factoryAtIdle; let valueDuringApply; let childParentsDuringApply; +let readChildParentsAfterApply; module.hot.accept("./module", () => { factoryDuringApply = __webpack_require__.m[removedModuleId]; if (removedModuleId === "./a.js") { valueDuringApply = readRemovedModule(); childParentsDuringApply = getChildParents(); + readChildParentsAfterApply = __webpack_require__("./a.js").readChildParents; } }); @@ -30,7 +32,17 @@ it("should preserve disposed factories only for the requested apply transaction" expect(getChildParents()).not.toContain("./a.js"); expect(factoryAtIdle).not.toBe(initialRemovedFactory); expect(__webpack_require__.m["./a.js"]).toBe(factoryAtIdle); + const warnings = []; const warn = console.warn; + console.warn = warning => warnings.push(warning); + try { + expect(readChildParentsAfterApply()).not.toContain("./a.js"); + } finally { + console.warn = warn; + } + expect(warnings).toEqual([ + "[HMR] unexpected require(./child.js) from disposed module ./a.js" + ]); console.warn = () => {}; try { expect(readRemovedModule).toThrow("RuntimeError: factory is undefined(./a.js)"); From 5a6b4f2c9c73983aaa6246ab46fb27e187f6642b Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:22:55 +0000 Subject: [PATCH 23/29] fix(copy-plugin): satisfy ordered-future clippy lint --- crates/rspack_plugin_copy/src/lib.rs | 64 +++++++++++++++------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index 015a6b618257..d28c1e855fde 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -524,23 +524,26 @@ impl CopyRspackPlugin { let output_path = &compilation.options.output.path; - let copied_result = FuturesOrdered::from_iter(entries.into_iter().map(|entry| { - Self::analyze_every_entry( - entry, - pattern, - &context, - output_path, - from_type, - file_dependencies, - diagnostics.clone(), - compilation, - logger, - ) - })) - .collect::>() - .await - .into_iter() - .collect::>>()?; + let copied_result = entries + .into_iter() + .map(|entry| { + Self::analyze_every_entry( + entry, + pattern, + &context, + output_path, + from_type, + file_dependencies, + diagnostics.clone(), + compilation, + logger, + ) + }) + .collect::>() + .collect::>() + .await + .into_iter() + .collect::>>()?; if copied_result.is_empty() { if pattern.no_error_on_missing { @@ -702,18 +705,21 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { } } logger.cache_end(cache_counter); - let pending_results = FuturesOrdered::from_iter(pending_patterns.iter().map(|pending| { - CopyRspackPlugin::run_pattern( - compilation, - pending.pattern, - &pending.file_dependencies, - &pending.context_dependencies, - pending.diagnostics.clone(), - &logger, - ) - })) - .collect::>() - .await; + let pending_results = pending_patterns + .iter() + .map(|pending| { + CopyRspackPlugin::run_pattern( + compilation, + pending.pattern, + &pending.file_dependencies, + &pending.context_dependencies, + pending.diagnostics.clone(), + &logger, + ) + }) + .collect::>() + .collect::>() + .await; let mut first_error = None; if !pending_patterns.is_empty() { From 99af575ec939a1cb679d06f59b56ca9927636469 Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:25:02 +0000 Subject: [PATCH 24/29] test(hmr): refresh disposed-module hot snapshots --- .../chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt | 3 ++- .../update-chunk-loading-runtime/__snapshots__/web/1.snap.txt | 3 ++- .../__snapshots__/web/1.snap.txt | 3 ++- .../runtime-independent-filename/__snapshots__/web/1.snap.txt | 3 ++- .../module/access-dropped-module/__snapshots__/web/1.snap.txt | 3 ++- .../hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt index 5036d88048fe..90d2da8235ad 100644 --- a/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt @@ -10,7 +10,7 @@ - Bundle: main.CURRENT_HASH.js - Manifest: main.LAST_HASH.hot-update.json, size: 41 - Update: initial_js.LAST_HASH.hot-update.js, size: 332 -- Update: main.LAST_HASH.hot-update.js, size: 21508 +- Update: main.LAST_HASH.hot-update.js, size: 21546 ## Manifest @@ -516,6 +516,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; diff --git a/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt index deb7fc6a9094..027a05cb4d33 100644 --- a/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/chunks/update-chunk-loading-runtime/__snapshots__/web/1.snap.txt @@ -12,7 +12,7 @@ - Bundle: vendors-node_modules_vendor_js.js - Manifest: runtime~main.LAST_HASH.hot-update.json, size: 76 - Update: main.LAST_HASH.hot-update.js, size: 660 -- Update: runtime~main.LAST_HASH.hot-update.js, size: 20884 +- Update: runtime~main.LAST_HASH.hot-update.js, size: 20922 - Update: vendors-node_modules_vendor_js.LAST_HASH.hot-update.js, size: 374 ## Manifest @@ -499,6 +499,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; diff --git a/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt index 47e165ce5105..39d6fe06066d 100644 --- a/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/disposing/remove-chunk-with-shared-in-other-runtime/__snapshots__/web/1.snap.txt @@ -8,7 +8,7 @@ - Bundle: chunk1_js.chunk.CURRENT_HASH.js - Manifest: [runtime of chunk2_js].LAST_HASH.hot-update.json, size: 60 - Manifest: main.LAST_HASH.hot-update.json, size: 41 -- Update: main.LAST_HASH.hot-update.js, size: 20218 +- Update: main.LAST_HASH.hot-update.js, size: 20256 ## Manifest @@ -455,6 +455,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; diff --git a/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt index ed819d0f94f7..7cc5ff2be64f 100644 --- a/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/disposing/runtime-independent-filename/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: chunk1_js.chunk.CURRENT_HASH.js -- Update: main.LAST_HASH.hot-update.js, size: 20218 +- Update: main.LAST_HASH.hot-update.js, size: 20256 ## Manifest @@ -439,6 +439,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; diff --git a/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt index 6ac8b5178c7e..a9cfbe2f81d5 100644 --- a/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/module/access-dropped-module/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Manifest: main.LAST_HASH.hot-update.json, size: 42 -- Update: main.LAST_HASH.hot-update.js, size: 17131 +- Update: main.LAST_HASH.hot-update.js, size: 17169 ## Manifest @@ -389,6 +389,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; diff --git a/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt index 1bf97ab0ef9d..8a2a1417e2b1 100644 --- a/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/sharing/share-plugin/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ - Bundle: bundle.js - Bundle: common_js_2.chunk.CURRENT_HASH.js - Manifest: main.LAST_HASH.hot-update.json, size: 28 -- Update: main.LAST_HASH.hot-update.js, size: 44265 +- Update: main.LAST_HASH.hot-update.js, size: 44303 ## Manifest @@ -1065,6 +1065,7 @@ function hotApplyHandler(options, moduleFactoryTransaction) { if (__webpack_require__.o(preservedModuleFactories, moduleId)) { var module = __webpack_require__.c[moduleId]; if (module) { + module.hot.active = false; for (var i = 0; i < module.children.length; i++) { var child = __webpack_require__.c[module.children[i]]; if (!child) continue; From f49bfbc84c53e53f6aed0c8aeb59513a7638513e Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:52:20 +0000 Subject: [PATCH 25/29] fix(watcher): preserve concrete context-child events during HMR --- .../rspack_binding_api/src/native_watcher.rs | 77 ++++++--- crates/rspack_watcher/src/disk_watcher.rs | 10 +- crates/rspack_watcher/src/executor.rs | 22 ++- crates/rspack_watcher/src/lib.rs | 23 ++- crates/rspack_watcher/src/scanner.rs | 14 +- crates/rspack_watcher/src/trigger.rs | 114 +++++++++++-- crates/rspack_watcher/tests/watcher.rs | 68 +++++++- packages/rspack/src/NativeWatchFileSystem.ts | 6 + .../NativeWatcherGeneration.test.js | 38 ++++- .../NativeWatcherRawEvents.test.js | 150 ++++++++++++++++++ 10 files changed, 458 insertions(+), 64 deletions(-) create mode 100644 tests/rspack-test/NativeWatcherRawEvents.test.js diff --git a/crates/rspack_binding_api/src/native_watcher.rs b/crates/rspack_binding_api/src/native_watcher.rs index 1a3f4ce6587f..2e815997c810 100644 --- a/crates/rspack_binding_api/src/native_watcher.rs +++ b/crates/rspack_binding_api/src/native_watcher.rs @@ -287,14 +287,16 @@ impl rspack_watcher::EventAggregateHandler for JsEventHandler { } struct JsEventHandlerUndelayed { - inner: napi::threadsafe_function::ThreadsafeFunction< - NativeWatchUndelayedEvent, - napi::Unknown<'static>, - NativeWatchUndelayedEvent, - Status, - false, - false, - 1, + inner: Option< + napi::threadsafe_function::ThreadsafeFunction< + NativeWatchUndelayedEvent, + napi::Unknown<'static>, + NativeWatchUndelayedEvent, + Status, + false, + false, + 0, + >, >, } @@ -303,35 +305,60 @@ impl JsEventHandlerUndelayed { let callback = callback .build_threadsafe_function::() .weak::() - .max_queue_size::<1>() + // A watch tick can produce a burst of concrete file events. Keep the + // raw stream lossless so later children cannot be silently dropped on + // QueueFull while the JS thread drains an earlier callback. + .max_queue_size::<0>() .build_callback( move |ctx: napi::threadsafe_function::ThreadSafeCallContext<_>| Ok(ctx.value), )?; - Ok(Self { inner: callback }) + Ok(Self { + inner: Some(callback), + }) } -} -impl rspack_watcher::EventHandler for JsEventHandlerUndelayed { - fn on_change(&self, changed_file: String) -> rspack_error::Result<()> { - self.inner.call( + fn deliver(&self, kind: &'static str, path: String) -> rspack_error::Result<()> { + let Some(inner) = &self.inner else { + return Err(rspack_error::error!( + "native watcher raw callback is closed" + )); + }; + let status = inner.call( NativeWatchUndelayedEvent { - kind: "change".to_string(), - path: changed_file, + kind: kind.to_string(), + path, }, napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking, ); - Ok(()) + if status == Status::Ok { + Ok(()) + } else { + Err(rspack_error::error!( + "native watcher raw callback could not be queued: {status}" + )) + } + } +} + +impl rspack_watcher::EventHandler for JsEventHandlerUndelayed { + fn on_change(&self, changed_file: String) -> rspack_error::Result<()> { + self.deliver("change", changed_file) } fn on_delete(&self, deleted_file: String) -> rspack_error::Result<()> { - self.inner.call( - NativeWatchUndelayedEvent { - kind: "remove".to_string(), - path: deleted_file, - }, - napi::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking, - ); - Ok(()) + self.deliver("remove", deleted_file) + } +} + +impl Drop for JsEventHandlerUndelayed { + fn drop(&mut self) { + if let Some(inner) = self.inner.take() { + // Releasing a TSFN drains its queue into the previous watch callback. + // Abort the obsolete callback so a rewatch cannot receive stale raw + // events after the handler generation has been replaced. + #[allow(deprecated)] + let _ = inner.abort(); + } } } diff --git a/crates/rspack_watcher/src/disk_watcher.rs b/crates/rspack_watcher/src/disk_watcher.rs index 16d8cb8911a5..2b00f6996daa 100644 --- a/crates/rspack_watcher/src/disk_watcher.rs +++ b/crates/rspack_watcher/src/disk_watcher.rs @@ -394,7 +394,10 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(10); let observed = loop { if let Ok(events) = rx.try_recv() - && events.iter().any(|event| event.path.as_ref() == file) + && events + .aggregated() + .iter() + .any(|event| event.path.as_ref() == file) { break true; } @@ -455,7 +458,10 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(10); let observed = loop { if let Ok(events) = rx.try_recv() - && events.iter().any(|event| event.path.as_ref() == parent) + && events + .aggregated() + .iter() + .any(|event| event.path.as_ref() == parent) { break true; } diff --git a/crates/rspack_watcher/src/executor.rs b/crates/rspack_watcher/src/executor.rs index 670377e6ae74..5a492ac240c9 100644 --- a/crates/rspack_watcher/src/executor.rs +++ b/crates/rspack_watcher/src/executor.rs @@ -247,7 +247,7 @@ impl Executor { while let Some(events) = rx.lock().await.recv().await { let should_aggregate = { let mut files_data = lock_pending(&files_data); - for event in &events { + for event in events.aggregated() { files_data.record(event.path.to_string_lossy().to_string(), event.kind); } files_data.schedule_if_needed() @@ -316,21 +316,27 @@ fn create_execute_task( while let Some(exec_event) = exec_rx.lock().await.recv().await { match exec_event { ExecEvent::Execute(batch_events) => { - for event in batch_events { - // Handle each event based on its kind + let handle_event = |event: crate::FsEvent| { let path = event.path.to_string_lossy().to_string(); match event.kind { super::FsEventKind::Change | super::FsEventKind::Create => { - if event_handler.on_change(path).is_err() { - break; - } + event_handler.on_change(path) } - super::FsEventKind::Remove => { - if event_handler.on_delete(path).is_err() { + super::FsEventKind::Remove => event_handler.on_delete(path), + } + }; + + match batch_events { + EventBatch::Shared(events) => { + for event in events { + if handle_event(event).is_err() { break; } } } + EventBatch::Split { undelayed, .. } => { + let _ = handle_event(undelayed); + } } } ExecEvent::Close => { diff --git a/crates/rspack_watcher/src/lib.rs b/crates/rspack_watcher/src/lib.rs index 51543e1c632a..f101dfe4b4d6 100644 --- a/crates/rspack_watcher/src/lib.rs +++ b/crates/rspack_watcher/src/lib.rs @@ -40,7 +40,28 @@ pub(crate) struct FsEvent { pub kind: FsEventKind, } -pub(crate) type EventBatch = Vec; +pub(crate) enum EventBatch { + Shared(Vec), + Split { + aggregated: Vec, + undelayed: FsEvent, + }, +} + +impl EventBatch { + pub fn aggregated(&self) -> &[FsEvent] { + match self { + Self::Shared(events) => events, + Self::Split { aggregated, .. } => aggregated, + } + } +} + +impl From> for EventBatch { + fn from(events: Vec) -> Self { + Self::Shared(events) + } +} /// `EventAggregateHandler` is a trait for handling aggregated file system events. /// It provides methods to handle changes and deletions of files, as well as errors. diff --git a/crates/rspack_watcher/src/scanner.rs b/crates/rspack_watcher/src/scanner.rs index 04e4298c0d0a..fa25b77331ae 100644 --- a/crates/rspack_watcher/src/scanner.rs +++ b/crates/rspack_watcher/src/scanner.rs @@ -110,7 +110,7 @@ fn scan_path_missing( if remove_event.is_empty() { return true; } - tx.send(remove_event).is_ok() + tx.send(remove_event.into()).is_ok() } fn scan_path_events( @@ -129,7 +129,7 @@ fn scan_path_events( if events.is_empty() { return true; } - tx.send(events).is_ok() + tx.send(events.into()).is_ok() } /// Whether `path`'s current on-disk mtime is at or after `start_time`, using @@ -180,7 +180,7 @@ mod tests { let collector = tokio::spawn(async move { let mut collected_events = Vec::new(); while let Some(event) = _rx.recv().await { - collected_events.push(event); + collected_events.push(event.aggregated().to_vec()); } collected_events }); @@ -251,9 +251,9 @@ mod tests { let mut changed_paths = HashSet::new(); while let Some(batch) = rx.recv().await { - for ev in batch { + for ev in batch.aggregated() { if ev.kind == FsEventKind::Change { - changed_paths.insert(ev.path); + changed_paths.insert(ev.path.clone()); } } } @@ -301,8 +301,8 @@ mod tests { let mut event_paths = HashSet::new(); while let Some(batch) = rx.recv().await { - for ev in batch { - event_paths.insert(ev.path); + for ev in batch.aggregated() { + event_paths.insert(ev.path.clone()); } } diff --git a/crates/rspack_watcher/src/trigger.rs b/crates/rspack_watcher/src/trigger.rs index 84496c2db626..5d5e3d6b980f 100644 --- a/crates/rspack_watcher/src/trigger.rs +++ b/crates/rspack_watcher/src/trigger.rs @@ -123,17 +123,22 @@ impl Trigger { // A watched path that no longer exists on disk is a removal, regardless of // how the OS reported the event. macOS FSEvents reports an unlink as a - // rename (`ModifyKind::Name` → `Change`), so normalize a `Change` whose - // path is gone into a `Remove`, keeping the event kind consistent with - // inotify (which already reports `Remove`). Done before the stale-event - // filter below, which only applies to `Change`/`Create`. - let kind = if kind == FsEventKind::Change && !path.exists() { + // rename (`ModifyKind::Name` → `Change`), and a rapid create/delete in a + // context may arrive as a delayed `Create` for an unregistered child. + // Normalize either event whose path is gone into a `Remove`, keeping the + // event kind consistent with inotify (which already reports `Remove`). Do + // this before the stale-event filter below. + let finder = self.finder(); + let kind = if (kind == FsEventKind::Change + || (kind == FsEventKind::Create && !finder.contains_path(path))) + && !path.exists() + { FsEventKind::Remove } else { kind }; - let is_registered_file = self.path_manager.access().files().0.contains(path); + let is_registered_file = finder.files.contains(path); // Filter stale FSEvents: on macOS, FSEvents can deliver events for files // written before the watcher was created. Stat the file and compare mtime @@ -146,9 +151,23 @@ impl Trigger { return; } - let finder = self.finder(); - let associated_event = finder.find_associated_event(path, kind); - self.trigger_events(associated_event); + let associated_events = finder.find_associated_event(path, kind); + if associated_events.is_empty() { + return; + } + + // Watchpack emits the concrete filesystem path to its undelayed listener, + // while compilation aggregation contains only registered dependencies. + // Keep those projections separate so context consumers can incrementally + // scan a new child without inflating `modifiedFiles` or duplicating parent + // callbacks. + self.trigger_events( + associated_events, + FsEvent { + path: path.clone(), + kind, + }, + ); } /// Helper to construct a `DependencyFinder` for the current path register state. fn finder(&self) -> DependencyFinder<'_> { @@ -167,15 +186,16 @@ impl Trigger { /// Sends a group of file system events for the given path and event kind. /// If the event is successfully sent, it returns true; otherwise, it returns false. - fn trigger_events(&self, events: Vec<(ArcPath, FsEventKind)>) -> bool { + fn trigger_events(&self, events: Vec<(ArcPath, FsEventKind)>, undelayed: FsEvent) -> bool { self .tx - .send( - events + .send(EventBatch::Split { + aggregated: events .into_iter() .map(|(path, kind)| FsEvent { path, kind }) .collect(), - ) + undelayed, + }) .is_ok() } } @@ -239,4 +259,72 @@ mod tests { assert!(associated_events.contains(&(dir_0, FsEventKind::Change))); assert!(associated_events.contains(&(dir_1, FsEventKind::Change))); } + + #[test] + fn test_find_dependency_for_context_file_events() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let context = temp_dir.path().join("context"); + let child = context.join("child.js"); + let removed = context.join("removed.js"); + let unrelated = temp_dir.path().join("unrelated.js"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(&child, "export default true;").unwrap(); + std::fs::write(&unrelated, "export default false;").unwrap(); + + let files = ArcPathDashSet::default(); + let directories = ArcPathDashSet::default(); + let missing = ArcPathDashSet::default(); + let context = ArcPath::from(context); + let child = ArcPath::from(child); + let removed = ArcPath::from(removed); + let unrelated = ArcPath::from(unrelated); + directories.insert(context.clone()); + + let finder = DependencyFinder { + files: &files, + directories: &directories, + missing: &missing, + }; + for kind in [ + FsEventKind::Create, + FsEventKind::Change, + FsEventKind::Remove, + ] { + let associated_events = finder.find_associated_event(&child, kind); + + assert_eq!(associated_events.len(), 1); + assert!(associated_events.contains(&(context.clone(), FsEventKind::Change))); + } + + let associated_events = finder.find_associated_event(&removed, FsEventKind::Remove); + assert_eq!(associated_events.len(), 1); + assert!(associated_events.contains(&(context, FsEventKind::Change))); + assert!( + finder + .find_associated_event(&unrelated, FsEventKind::Create) + .is_empty() + ); + } + + #[test] + fn test_find_dependency_emits_removed_directory_once() { + let files = ArcPathDashSet::default(); + let directories = ArcPathDashSet::default(); + let missing = ArcPathDashSet::default(); + let parent = ArcPath::from(Path::new("/path/a")); + let child = ArcPath::from(Path::new("/path/a/removed")); + directories.insert(parent.clone()); + directories.insert(child.clone()); + + let finder = DependencyFinder { + files: &files, + directories: &directories, + missing: &missing, + }; + let associated_events = finder.find_associated_event(&child, FsEventKind::Remove); + + assert_eq!(associated_events.len(), 2); + assert!(associated_events.contains(&(child, FsEventKind::Remove))); + assert!(associated_events.contains(&(parent, FsEventKind::Change))); + } } diff --git a/crates/rspack_watcher/tests/watcher.rs b/crates/rspack_watcher/tests/watcher.rs index 2233e46664f1..07d57c0341a7 100644 --- a/crates/rspack_watcher/tests/watcher.rs +++ b/crates/rspack_watcher/tests/watcher.rs @@ -147,18 +147,78 @@ fn should_watch_a_file_created_in_an_existing_context_child() { helper.file("assets/empty-child/created.txt"); }); - let change_events = c!(); + let created_events = c!(); helper.collect_events( rx, |event, _| { - event.assert_changed(helper.join("assets")); - add!(change_events); + if let helpers::ChangedEvent::Changed(path) = event + && path == helper.join("assets/empty-child/created.txt").as_str() + { + add!(created_events); + } + }, + |changes, abort| { + if load!(created_events) == 0 { + return; + } + changes.assert_changed(helper.join("assets")); + assert!( + !changes + .changed_files + .contains(helper.join("assets/empty-child/created.txt").as_str()) + ); + *abort = true; + }, + ); + + assert!(load!(created_events) > 0); +} + +#[test] +fn should_emit_existing_and_created_files_in_the_same_context_batch() { + let mut helper = h!(FsWatcherOptions { + aggregate_timeout: Some(100), + ..Default::default() + }); + std::fs::create_dir_all(helper.join("assets")).unwrap(); + helper.file("assets/existing.js"); + + let rx = helper.watch(f!("assets/existing.js"), f!("assets"), e!()); + + helper.tick(|| { + helper.file("assets/existing.js"); + helper.file("assets/created.js"); + }); + + let existing_events = c!(); + let created_events = c!(); + helper.collect_events( + rx, + |event, _| { + if let helpers::ChangedEvent::Changed(path) = event { + if path == helper.join("assets/existing.js").as_str() { + add!(existing_events); + } + if path == helper.join("assets/created.js").as_str() { + add!(created_events); + } + } }, |changes, abort| { + if load!(created_events) == 0 { + return; + } changes.assert_changed(helper.join("assets")); + changes.assert_changed(helper.join("assets/existing.js")); + assert!( + !changes + .changed_files + .contains(helper.join("assets/created.js").as_str()) + ); *abort = true; }, ); - assert!(load!(change_events) > 0); + assert!(load!(existing_events) > 0); + assert!(load!(created_events) > 0); } diff --git a/packages/rspack/src/NativeWatchFileSystem.ts b/packages/rspack/src/NativeWatchFileSystem.ts index 995d6f804a7a..5d74250b7a87 100644 --- a/packages/rspack/src/NativeWatchFileSystem.ts +++ b/packages/rspack/src/NativeWatchFileSystem.ts @@ -211,6 +211,9 @@ export default class NativeWatchFileSystem implements WatchFileSystem { callback(err, new Map(), new Map(), changes, removals); }, (event) => { + if (this.#watcher !== watcher) { + return; + } if (event.kind === 'change') { // The native watcher reports paths without an mtime, so events are // stamped with their arrival time. @@ -229,6 +232,9 @@ export default class NativeWatchFileSystem implements WatchFileSystem { return { close: () => { + if (this.#watcher === watcher) { + this.#watcher = undefined; + } if (this.#inner === nativeWatcher) { this.#inner = undefined; this.#isFirstWatch = true; diff --git a/tests/rspack-test/NativeWatcherGeneration.test.js b/tests/rspack-test/NativeWatcherGeneration.test.js index 061b6a19eadf..b6764fbfaa39 100644 --- a/tests/rspack-test/NativeWatcherGeneration.test.js +++ b/tests/rspack-test/NativeWatcherGeneration.test.js @@ -9,8 +9,9 @@ class FakeNativeWatcher { generation: 0 }; - watch(_files, _directories, _missing, _startTime, onAggregate) { + watch(_files, _directories, _missing, _startTime, onAggregate, onRaw) { this.onAggregate = onAggregate; + this.onRaw = onRaw; } takePendingEvents() { @@ -36,7 +37,7 @@ const dependencies = () => removed: [] }); -function createWatcherHarness() { +function createWatcherHarness(callbackUndelayed = () => {}) { const nativeWatcher = new FakeNativeWatcher(); const purged = []; const callbackChanges = []; @@ -57,10 +58,10 @@ function createWatcherHarness() { {}, (_error, _fileTimes, _contextTimes, changes) => callbackChanges.push(changes), - () => {} + callbackUndelayed ); - return { nativeWatcher, purged, callbackChanges, watcher }; + return { nativeWatcher, purged, callbackChanges, watcher, watchFileSystem }; } describe("NativeWatchFileSystem aggregate generations", () => { @@ -124,4 +125,33 @@ describe("NativeWatchFileSystem aggregate generations", () => { expect(purged).toEqual(["/drained", "/wrapped"]); expect(callbackChanges).toEqual([new Set(["/wrapped"])]); }); + + it("ignores a raw callback retained by an earlier watch generation", () => { + const stale = []; + const fresh = []; + const { nativeWatcher, watchFileSystem } = createWatcherHarness(path => + stale.push(path) + ); + const staleRaw = nativeWatcher.onRaw; + + const currentWatcher = watchFileSystem.watch( + dependencies(), + dependencies(), + dependencies(), + Date.now(), + {}, + () => {}, + path => fresh.push(path) + ); + + staleRaw({ kind: "change", path: "/stale" }); + nativeWatcher.onRaw({ kind: "change", path: "/fresh" }); + + expect(stale).toEqual([]); + expect(fresh).toEqual(["/fresh"]); + + currentWatcher.close(); + nativeWatcher.onRaw({ kind: "change", path: "/after-close" }); + expect(fresh).toEqual(["/fresh"]); + }); }); diff --git a/tests/rspack-test/NativeWatcherRawEvents.test.js b/tests/rspack-test/NativeWatcherRawEvents.test.js new file mode 100644 index 000000000000..37c49ffd0d22 --- /dev/null +++ b/tests/rspack-test/NativeWatcherRawEvents.test.js @@ -0,0 +1,150 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { NativeWatcher } = require('@rspack/binding'); + +describe('NativeWatcher raw context events', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rspack-native-raw-')); + const context = path.join(root, 'src'); + const ignored = path.join(context, 'ignored'); + const existing = path.join(context, 'existing.js'); + const created = path.join(context, 'created.js'); + const ignoredFile = path.join(ignored, 'ignored.js'); + let watcher; + + beforeAll(() => { + fs.mkdirSync(ignored, { recursive: true }); + fs.writeFileSync(existing, 'export default 1;\n'); + }); + + afterAll(async () => { + await watcher?.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('delivers concrete created and removed children without dropping a raw burst', async () => { + watcher = new NativeWatcher({ + aggregateTimeout: 10, + ignored: ['**/ignored/**'], + }); + const rawEvents = []; + const aggregated = []; + const errors = []; + + watcher.watch( + [[existing], []], + [[context], []], + [[], []], + BigInt(Date.now()), + (error, result) => { + if (error) errors.push(error); + if (result) { + aggregated.push(result); + watcher.acknowledgePendingEvents(result.generation); + } + }, + (event) => rawEvents.push(event), + ); + + const waitFor = async (predicate) => { + const deadline = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error( + `timed out waiting for native raw events: ${JSON.stringify(rawEvents)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + + fs.writeFileSync(existing, 'export default 2;\n'); + const changedAt = new Date(Date.now() + 2000); + fs.utimesSync(existing, changedAt, changedAt); + watcher.triggerEvent('change', existing); + await waitFor(() => rawEvents.some((event) => event.path === existing)); + rawEvents.length = 0; + aggregated.length = 0; + + fs.writeFileSync(existing, 'export default 3;\n'); + const nextChangedAt = new Date(Date.now() + 4000); + fs.utimesSync(existing, nextChangedAt, nextChangedAt); + fs.writeFileSync(created, 'export default true;\n'); + fs.writeFileSync(ignoredFile, 'export default false;\n'); + watcher.triggerEvent('change', existing); + watcher.triggerEvent('create', created); + watcher.triggerEvent('create', ignoredFile); + + await waitFor( + () => + rawEvents.some( + (event) => event.kind === 'change' && event.path === existing, + ) && + rawEvents.some( + (event) => event.kind === 'change' && event.path === created, + ) && + aggregated.some( + (result) => + result.changedFiles.includes(context) && + result.changedFiles.includes(existing), + ), + ); + expect(rawEvents.some((event) => event.path === ignoredFile)).toBe(false); + expect( + aggregated.some((result) => result.changedFiles.includes(ignoredFile)), + ).toBe(false); + expect( + aggregated.some((result) => result.changedFiles.includes(created)), + ).toBe(false); + + const burst = Array.from({ length: 128 }, (_, index) => + path.join(context, `created-${index}.js`), + ); + rawEvents.length = 0; + aggregated.length = 0; + for (const file of burst) { + fs.writeFileSync(file, 'export default true;\n'); + watcher.triggerEvent('create', file); + } + await waitFor( + () => + burst.every((file) => + rawEvents.some( + (event) => event.kind === 'change' && event.path === file, + ), + ) && aggregated.some((result) => result.changedFiles.includes(context)), + ); + expect( + aggregated.some((result) => + burst.some((file) => result.changedFiles.includes(file)), + ), + ).toBe(false); + + rawEvents.length = 0; + aggregated.length = 0; + fs.rmSync(created); + watcher.triggerEvent('remove', created); + await waitFor( + () => + rawEvents.some( + (event) => event.kind === 'remove' && event.path === created, + ) && aggregated.some((result) => result.changedFiles.includes(context)), + ); + + expect( + aggregated.some((result) => result.removedFiles.includes(created)), + ).toBe(false); + + rawEvents.length = 0; + aggregated.length = 0; + watcher.triggerEvent('create', created); + await waitFor( + () => + rawEvents.some( + (event) => event.kind === 'remove' && event.path === created, + ) && aggregated.some((result) => result.changedFiles.includes(context)), + ); + + expect(errors).toEqual([]); + }); +}); From df63754484e7800ba56656331c980c2c7ec6c6da Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:24:43 +0000 Subject: [PATCH 26/29] fix(copy-plugin): keep rendered template asset keys portable --- crates/rspack_plugin_copy/src/lib.rs | 4 ++-- tests/rspack-test/CopyPluginCache.test.js | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index d28c1e855fde..56d0ccd549f3 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -356,9 +356,9 @@ impl CopyRspackPlugin { template_str } else { - let filename = filename.as_str().normalize().to_string_lossy().to_string(); - normalize_glob_path_separators(&filename).into_owned() + filename.as_str().normalize().to_string_lossy().to_string() }; + let filename = normalize_glob_path_separators(&filename).into_owned(); Ok(Some(RunPatternResult { source_filename, diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js index f7eb28fedfb7..b10a10e9c31a 100644 --- a/tests/rspack-test/CopyPluginCache.test.js +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -320,18 +320,27 @@ describe("CopyRspackPlugin pattern cache", () => { } }, { from: "assets/template", to: "template/[name][ext]" }, + { from: "assets/nested-template", to: "template-path/[path][name][ext]" }, { from: "assets/permissions", to: "permissions", copyPermissions: true } ]); write(root, "assets/static/one.txt", "static\n"); write(root, "assets/transform/one.txt", "transform\n"); write(root, "assets/function/one.txt", "function\n"); write(root, "assets/template/one.txt", "template\n"); + write(root, "assets/nested-template/deep/two.txt", "nested-template\n"); write(root, "assets/permissions/one.txt", "permissions\n"); try { - await compile(compiler); + const initial = await compile(compiler); expect(transformCalls).toBe(1); expect(destinationCalls).toBe(1); + expect(asset(initial, "template/one.txt")).toBe("template\n"); + expect(asset(initial, "template-path/deep/two.txt")).toBe( + "nested-template\n" + ); + expect(initial.getAssets().some(({ name }) => name.includes("\\"))).toBe( + false + ); const entry = write(root, "src/index.js", "module.exports = 'changed';\n"); const updated = await rebuild(compiler, [entry]); @@ -343,6 +352,12 @@ describe("CopyRspackPlugin pattern cache", () => { expect(asset(updated, "transform/one.txt")).toBe("transform\n"); expect(asset(updated, "function/one.txt")).toBe("function\n"); expect(asset(updated, "template/one.txt")).toBe("template\n"); + expect(asset(updated, "template-path/deep/two.txt")).toBe( + "nested-template\n" + ); + expect(updated.getAssets().some(({ name }) => name.includes("\\"))).toBe( + false + ); expect(asset(updated, "permissions/one.txt")).toBe("permissions\n"); } finally { await close(compiler); From ef90a0f04b56ffabece4e483683f7e1c630b417a Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:04:25 +0000 Subject: [PATCH 27/29] fix(hmr): keep extracted css reload single-owned Avoid racing the extracted-CSS runtime with the loader-level reload while preserving the runtime:false fallback. Extend the incremental browser regression with repeated edits and exact request/link counts, and refresh the two affected hot snapshots. --- .../src/builtin-plugin/css-extract/loader.ts | 6 ++- .../index.test.ts | 36 +++++++++++++++ .../rspack.config.js | 44 +++++++++++++++++++ .../src/index.css | 3 ++ .../src/index.html | 7 +++ .../src/index.js | 3 ++ .../css/shared-stylesheet-dedup/index.test.ts | 42 +++++++++++------- .../__snapshots__/web/2.snap.txt | 6 ++- .../css/recovery/__snapshots__/web/2.snap.txt | 6 ++- 9 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/index.test.ts create mode 100644 tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/rspack.config.js create mode 100644 tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.css create mode 100644 tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.html create mode 100644 tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.js diff --git a/packages/rspack/src/builtin-plugin/css-extract/loader.ts b/packages/rspack/src/builtin-plugin/css-extract/loader.ts index 86468273ae98..3d879e9bb158 100644 --- a/packages/rspack/src/builtin-plugin/css-extract/loader.ts +++ b/packages/rspack/src/builtin-plugin/css-extract/loader.ts @@ -39,6 +39,8 @@ export function hotLoader( }, ): string { const localsJsonString = JSON.stringify(JSON.stringify(context.locals)); + // The extracted-CSS runtime owns stylesheet replacement when present; keep + // the loader reload only as the fallback for `CssExtractRspackPlugin({ runtime: false })`. return `${content} if(module.hot) { (function() { @@ -60,7 +62,9 @@ export function hotLoader( } module.hot.dispose(function(data) { data.value = localsJsonString; - cssReload(); + if (!__webpack_require__.hmrC || !__webpack_require__.hmrC.miniCss) { + cssReload(); + } }); })(); } diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/index.test.ts b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/index.test.ts new file mode 100644 index 000000000000..1829749c04e5 --- /dev/null +++ b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/index.test.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@/fixtures'; + +test('keeps the loader CSS reload fallback when the extracted runtime is disabled', async ({ + page, + fileAction, +}) => { + const links = page.locator('link[rel="stylesheet"]'); + const responses: string[] = []; + const colors = Array.from( + { length: 21 }, + (_, index) => `rgb(${10 + index}, ${20 + index}, ${30 + index})`, + ); + + page.on('response', (response) => { + const url = response.url(); + if (url.includes('/static/style.css')) responses.push(url); + }); + + await expect(page.locator('body')).toHaveCSS('background-color', colors[0]); + await expect(links).toHaveCount(1); + + for (let index = 1; index < colors.length; index++) { + const previous = colors[index - 1]; + const next = colors[index]; + + fileAction.updateFile('src/index.css', (content) => + content.replace(previous, next), + ); + await expect(page.locator('body')).toHaveCSS('background-color', next); + await expect(links).toHaveCount(1); + } + + expect(responses).toHaveLength(colors.length - 1); + expect(new Set(responses).size).toBe(colors.length - 1); + expect(responses.every((url) => url.includes('?'))).toBe(true); +}); diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/rspack.config.js b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/rspack.config.js new file mode 100644 index 000000000000..b09867a80d00 --- /dev/null +++ b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/rspack.config.js @@ -0,0 +1,44 @@ +const { rspack } = require('@rspack/core'); + +/** @type { import('@rspack/core').RspackOptions } */ +module.exports = { + context: __dirname, + mode: 'development', + entry: { + main: ['./src/index.css', './src/index.js'], + }, + devServer: { + hot: true, + }, + plugins: [ + new rspack.HtmlRspackPlugin({ + template: './src/index.html', + inject: 'body', + }), + new rspack.CssExtractRspackPlugin({ + filename: 'static/style.css', + runtime: false, + }), + ], + module: { + rules: [ + { + test: /\.css$/, + type: 'javascript/auto', + use: [rspack.CssExtractRspackPlugin.loader, 'css-loader'], + }, + ], + }, + optimization: { + splitChunks: { + cacheGroups: { + style: { + name: 'style', + test: /\.css$/, + chunks: 'all', + enforce: true, + }, + }, + }, + }, +}; diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.css b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.css new file mode 100644 index 000000000000..6542fae55119 --- /dev/null +++ b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(10, 20, 30); +} diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.html b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.html new file mode 100644 index 000000000000..7c26f485c41b --- /dev/null +++ b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.js b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.js new file mode 100644 index 000000000000..b831a14b164b --- /dev/null +++ b/tests/e2e/cases/css/shared-stylesheet-dedup-runtime-false/src/index.js @@ -0,0 +1,3 @@ +import './index.css'; + +module.hot.accept(); diff --git a/tests/e2e/cases/css/shared-stylesheet-dedup/index.test.ts b/tests/e2e/cases/css/shared-stylesheet-dedup/index.test.ts index 1fbc0e954d92..c21b0d5df2a7 100644 --- a/tests/e2e/cases/css/shared-stylesheet-dedup/index.test.ts +++ b/tests/e2e/cases/css/shared-stylesheet-dedup/index.test.ts @@ -1,9 +1,5 @@ import { test, expect } from '@/fixtures'; -const COLOR_BLUE = 'rgb(10, 20, 30)'; -const COLOR_RED = 'rgb(120, 0, 0)'; -const COLOR_GREEN = 'rgb(0, 90, 0)'; - // The hot-update lists both the `style` and `main` chunks while the fixed // `filename` maps them to one stylesheet. Without de-duplication the handler // re-fetched it once per chunk and leaked one per update. @@ -12,18 +8,34 @@ test('should keep a single stylesheet link when several updated chunks share it' fileAction, }) => { const links = page.locator('link[rel="stylesheet"]'); - await expect(page.locator('body')).toHaveCSS('background-color', COLOR_BLUE); - await expect(links).toHaveCount(1); - - fileAction.updateFile('src/index.css', (content) => - content.replace(COLOR_BLUE, COLOR_RED), + const responses: string[] = []; + const colors = Array.from( + { length: 21 }, + (_, index) => `rgb(${10 + index}, ${20 + index}, ${30 + index})`, ); - await expect(page.locator('body')).toHaveCSS('background-color', COLOR_RED); - await expect(links).toHaveCount(1); - fileAction.updateFile('src/index.css', (content) => - content.replace(COLOR_RED, COLOR_GREEN), - ); - await expect(page.locator('body')).toHaveCSS('background-color', COLOR_GREEN); + page.on('response', (response) => { + const url = response.url(); + if (url.includes('/static/style.css')) responses.push(url); + }); + + await expect(page.locator('body')).toHaveCSS('background-color', colors[0]); await expect(links).toHaveCount(1); + + for (let index = 1; index < colors.length; index++) { + const previous = colors[index - 1]; + const next = colors[index]; + + fileAction.updateFile('src/index.css', (content) => + content.replace(previous, next), + ); + await expect(page.locator('body')).toHaveCSS('background-color', next); + await expect(links).toHaveCount(1); + } + + // The loader-level reload is debounced, so repeated edits expose a second + // owner even when a transient two-link state has already settled. + expect(responses).toHaveLength(colors.length - 1); + expect(new Set(responses).size).toBe(colors.length - 1); + expect(responses.every((url) => url.includes('?'))).toBe(true); }); diff --git a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt index 56801c282e5e..64d989c76dd6 100644 --- a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Manifest: main.LAST_HASH.hot-update.json, size: 28 -- Update: main.hot-update.js, size: 993 +- Update: main.hot-update.js, size: 1087 ## Manifest @@ -52,7 +52,9 @@ __webpack_require__.r(__webpack_exports__); } module.hot.dispose(function(data) { data.value = localsJsonString; - cssReload(); + if (!__webpack_require__.hmrC || !__webpack_require__.hmrC.miniCss) { + cssReload(); + } }); })(); } diff --git a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt index c95656f6c6eb..e64a13b5e34d 100644 --- a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Manifest: main.LAST_HASH.hot-update.json, size: 28 -- Update: main.hot-update.js, size: 6038 +- Update: main.hot-update.js, size: 6132 ## Manifest @@ -54,7 +54,9 @@ __webpack_require__.r(__webpack_exports__); } module.hot.dispose(function(data) { data.value = localsJsonString; - cssReload(); + if (!__webpack_require__.hmrC || !__webpack_require__.hmrC.miniCss) { + cssReload(); + } }); })(); } From eb10e07b5d68ef1b8eeb4f6dac14408abe79984d Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:27:21 +0000 Subject: [PATCH 28/29] 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. --- crates/node_binding/napi-binding.d.ts | 2 +- crates/rspack_binding_api/src/lib.rs | 6 +- crates/rspack_core/src/compilation/mod.rs | 3 + crates/rspack_core/src/compiler/rebuild.rs | 18 ++- crates/rspack_plugin_copy/src/lib.rs | 3 + .../rspack_plugin_copy/src/pattern_cache.rs | 14 +- packages/rspack/src/Compiler.ts | 1 + tests/rspack-test/CopyPluginCache.test.js | 146 +++++++++++++++++- 8 files changed, 179 insertions(+), 14 deletions(-) diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index 1a1493d24dd2..86f53d49b1a8 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -340,7 +340,7 @@ export declare class JsCompiler { /** Build with the given option passed to the constructor */ build(callback: (err: null | Error) => void): void /** Rebuild with the given option passed to the constructor */ - rebuild(changed_files: string[], removed_files: string[], callback: (err: null | Error) => void): void + rebuild(changed_files: string[], removed_files: string[], callback: (err: null | Error) => void, is_lazy_watch_rebuild?: boolean): void close(): Promise getVirtualFileStore(): VirtualFileStore | null getCompilerId(): ExternalObject diff --git a/crates/rspack_binding_api/src/lib.rs b/crates/rspack_binding_api/src/lib.rs index cc581a6c914f..817eda2bd9c0 100644 --- a/crates/rspack_binding_api/src/lib.rs +++ b/crates/rspack_binding_api/src/lib.rs @@ -390,7 +390,7 @@ impl JsCompiler { /// Rebuild with the given option passed to the constructor #[napi( - ts_args_type = "changed_files: string[], removed_files: string[], callback: (err: null | Error) => void" + ts_args_type = "changed_files: string[], removed_files: string[], callback: (err: null | Error) => void, is_lazy_watch_rebuild?: boolean" )] pub fn rebuild( &mut self, @@ -398,6 +398,7 @@ impl JsCompiler { changed_files: Vec, removed_files: Vec, f: Function<'static>, + is_lazy_watch_rebuild: Option, ) -> Result<(), ErrorCode> { unsafe { self.run(reference, |compiler, guard| { @@ -405,9 +406,10 @@ impl JsCompiler { f, async move { let result = compiler - .rebuild( + .rebuild_with_invalidation_provenance( changed_files.into_iter().collect::>(), removed_files.into_iter().collect::>(), + is_lazy_watch_rebuild.unwrap_or(false), ) .await .to_napi_result_with_message(|e| { diff --git a/crates/rspack_core/src/compilation/mod.rs b/crates/rspack_core/src/compilation/mod.rs index c558ac205e3b..1bc36dc36a65 100644 --- a/crates/rspack_core/src/compilation/mod.rs +++ b/crates/rspack_core/src/compilation/mod.rs @@ -316,6 +316,8 @@ pub struct Compilation { /// /// Rebuild will include previous compilation data, so persistent cache will not recovery anything pub is_rebuild: bool, + /// Whether this rebuild was explicitly invalidated by lazy compilation. + pub is_lazy_watch_rebuild: bool, pub compiler_context: Arc, } @@ -445,6 +447,7 @@ impl Compilation { intermediate_filesystem, output_filesystem, is_rebuild, + is_lazy_watch_rebuild: false, compiler_context, } } diff --git a/crates/rspack_core/src/compiler/rebuild.rs b/crates/rspack_core/src/compiler/rebuild.rs index 60f74b0507bc..4cb12d6532dc 100644 --- a/crates/rspack_core/src/compiler/rebuild.rs +++ b/crates/rspack_core/src/compiler/rebuild.rs @@ -20,10 +20,21 @@ impl Compiler { &mut self, changed_files: FxHashSet, deleted_files: FxHashSet, + ) -> Result<()> { + self + .rebuild_with_invalidation_provenance(changed_files, deleted_files, false) + .await + } + + pub async fn rebuild_with_invalidation_provenance( + &mut self, + changed_files: FxHashSet, + deleted_files: FxHashSet, + is_lazy_watch_rebuild: bool, ) -> Result<()> { match within_compiler_context( self.compiler_context.clone(), - self.rebuild_inner(changed_files, deleted_files), + self.rebuild_inner(changed_files, deleted_files, is_lazy_watch_rebuild), ) .await { @@ -50,12 +61,14 @@ impl Compiler { #[tracing::instrument("Compiler:rebuild", skip_all, fields( compiler.changed_files = ?changed_files.iter().cloned().collect::>(), - compiler.deleted_files = ?deleted_files.iter().cloned().collect::>() + compiler.deleted_files = ?deleted_files.iter().cloned().collect::>(), + compiler.is_lazy_watch_rebuild = is_lazy_watch_rebuild ))] async fn rebuild_inner( &mut self, changed_files: FxHashSet, deleted_files: FxHashSet, + is_lazy_watch_rebuild: bool, ) -> Result<()> { let records = self.last_records.clone(); @@ -93,6 +106,7 @@ impl Compiler { true, self.compiler_context.clone(), ); + next_compilation.is_lazy_watch_rebuild = is_lazy_watch_rebuild; next_compilation.hot_index = self.compilation.hot_index + 1; if next_compilation diff --git a/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index 56d0ccd549f3..317bbadcd147 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -676,6 +676,9 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { let cached = &mut pattern_cache[index]; if cacheable + && (compilation.is_lazy_watch_rebuild + || !compilation.modified_files.is_empty() + || !compilation.removed_files.is_empty()) && let Some(cached) = cached.as_ref() && !cached.is_invalidated( compilation diff --git a/crates/rspack_plugin_copy/src/pattern_cache.rs b/crates/rspack_plugin_copy/src/pattern_cache.rs index 06a50216c52f..6b9b888656c0 100644 --- a/crates/rspack_plugin_copy/src/pattern_cache.rs +++ b/crates/rspack_plugin_copy/src/pattern_cache.rs @@ -10,12 +10,10 @@ pub(super) struct CachedPatternResult { } impl CachedPatternResult { - pub(super) fn is_invalidated<'a>(&self, changed_paths: impl Iterator) -> bool { - let mut changed_paths = changed_paths.peekable(); - if changed_paths.peek().is_none() { - return true; - } - + pub(super) fn is_invalidated<'a>( + &self, + mut changed_paths: impl Iterator, + ) -> bool { changed_paths.any(|changed| { self .file_dependencies @@ -56,10 +54,10 @@ mod tests { } #[test] - fn reuses_only_when_unrelated_watcher_provenance_exists() { + fn reuses_for_empty_and_unrelated_changes() { let cached = cached(&["/project/assets/file.txt"], &["/project/assets/glob"]); assert!(!cached.is_invalidated([Path::new("/project/src/index.js")].into_iter())); - assert!(cached.is_invalidated(std::iter::empty())); + assert!(!cached.is_invalidated(std::iter::empty())); } } diff --git a/packages/rspack/src/Compiler.ts b/packages/rspack/src/Compiler.ts index 15a728e20b54..8eabb794f0e4 100644 --- a/packages/rspack/src/Compiler.ts +++ b/packages/rspack/src/Compiler.ts @@ -848,6 +848,7 @@ class Compiler { Array.from(this.modifiedFiles || []), Array.from(this.removedFiles || []), callback, + this.__internal__watchInvalidationKind === 'lazy', ); return; } diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js index b10a10e9c31a..2fbbaf3c2b7e 100644 --- a/tests/rspack-test/CopyPluginCache.test.js +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -1,6 +1,11 @@ const fs = require("node:fs"); const path = require("node:path"); -const { CopyRspackPlugin, Stats, rspack } = require("@rspack/core"); +const { + CopyRspackPlugin, + Stats, + lazyCompilationMiddleware, + rspack +} = require("@rspack/core"); const fixtureRoot = path.resolve(__dirname, "js/copy-plugin-cache"); @@ -112,6 +117,9 @@ function watch(compiler) { return new Promise((resolve, reject) => { watching.close(error => (error ? reject(error) : resolve())); }); + }, + invalidate() { + watching.invalidate(); } }; } @@ -384,6 +392,142 @@ describe("CopyRspackPlugin pattern cache", () => { } }); + it("reuses a 2000-file copy pattern during an empty lazy watch rebuild", async () => { + const fileCount = 2000; + const root = path.join(fixtureRoot, "lazy-watch-cache-hit"); + const beforeWatch = new Date(Date.now() - 3000); + fs.rmSync(root, { recursive: true, force: true }); + const entry = write(root, "src/index.js", "export const value = 'lazy';\n"); + for (let index = 0; index < fileCount; index += 1) { + const asset = write(root, `assets/${index}.txt`, `${index}\n`); + fs.utimesSync(asset, beforeWatch, beforeWatch); + } + for (const filename of [ + path.join(root, "src"), + path.join(root, "assets"), + entry + ]) { + fs.utimesSync(filename, beforeWatch, beforeWatch); + } + + const compiler = rspack({ + context: root, + mode: "development", + target: "web", + devtool: false, + cache: true, + incremental: true, + entry: "./src/index.js", + lazyCompilation: { entries: true, imports: false }, + output: { path: path.join(root, "dist"), filename: "main.js" }, + plugins: [ + new CopyRspackPlugin({ patterns: [{ from: "assets", to: "copied" }] }) + ] + }); + const middleware = lazyCompilationMiddleware(compiler); + const watching = watch(compiler); + + try { + const initial = await watching.next(); + expect(initial.error).toBeNull(); + expect(initial.stats.hasErrors()).toBe(false); + expect( + initial.stats.compilation + .getAssets() + .filter(({ name }) => name.startsWith("copied/")) + ).toHaveLength(fileCount); + await new Promise(resolve => setTimeout(resolve, 200)); + + const bundle = fs.readFileSync(path.join(root, "dist/main.js"), "utf8"); + const encoded = bundle.match(/var data = ("(?:[^"\\]|\\.)*")/)?.[1]; + expect(encoded).toBeDefined(); + const moduleId = JSON.parse(encoded); + + await new Promise((resolve, reject) => { + Promise.resolve( + middleware( + { + body: [moduleId], + method: "POST", + url: "/_rspack/lazy/trigger" + }, + { + end: resolve, + write() {}, + writeHead(status) { + expect(status).toBe(200); + } + }, + reject + ) + ).catch(reject); + }); + const updated = await watching.next(); + + expect(updated.error).toBeNull(); + expect(updated.stats.hasErrors()).toBe(false); + expect(updated.stats.compilation.watchInvalidationKind).toBe("lazy"); + expect(compiler.modifiedFiles).toEqual(new Set()); + expect(compiler.removedFiles).toEqual(new Set()); + expect(reusedPatterns(updated.stats.compilation)).toBe(1); + expect( + updated.stats.compilation + .getAssets() + .filter(({ name }) => name.startsWith("copied/")) + ).toHaveLength(fileCount); + } finally { + await watching.close(); + } + }); + + it("does not reuse a copy pattern for an empty normal watch invalidation", async () => { + const { root, compiler } = createCompiler("normal-watch-invalidation", [ + { from: "assets/source", to: "copied" } + ]); + const source = write(root, "assets/source/one.txt", "before\n"); + const beforeWatch = new Date(Date.now() - 3000); + for (const filename of [ + path.join(root, "src"), + path.join(root, "src/index.js"), + path.join(root, "assets"), + path.join(root, "assets/source"), + source + ]) { + fs.utimesSync(filename, beforeWatch, beforeWatch); + } + let mutateDuringWatchRun = false; + compiler.hooks.watchRun.tap("copy-plugin-cache-test", () => { + if (!mutateDuringWatchRun) return; + mutateDuringWatchRun = false; + const { atime, mtime } = fs.statSync(source); + write(root, "assets/source/one.txt", "after\n"); + fs.utimesSync(source, atime, mtime); + }); + const watching = watch(compiler); + + try { + const initial = await watching.next(); + expect(initial.error).toBeNull(); + expect(initial.stats.hasErrors()).toBe(false); + expect(asset(initial.stats.compilation, "copied/one.txt")).toBe("before\n"); + await new Promise(resolve => setTimeout(resolve, 200)); + + mutateDuringWatchRun = true; + watching.invalidate(); + const updated = await watching.next(); + + expect(updated.error).toBeNull(); + expect(updated.stats.hasErrors()).toBe(false); + expect(updated.stats.compilation.watchInvalidationKind).toBe("normal"); + expect(compiler.modifiedFiles).toEqual(new Set()); + expect(compiler.removedFiles).toEqual(new Set()); + expect(asset(updated.stats.compilation, "copied/one.txt")).toBe("after\n"); + expect(reusedPatterns(updated.stats.compilation)).toBe(0); + } finally { + await watching.close(); + } + }); + it("preserves equal-priority copy order with interleaved cache hits and misses", async () => { const patternCount = 64; const patterns = Array.from({ length: patternCount }, (_, index) => ({ From e92e185475ece0d009a66b52c6e6679c21f4a76a Mon Sep 17 00:00:00 2001 From: Matthew Davis <276116661+matthewdavis-oai@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:27:21 +0000 Subject: [PATCH 29/29] fix(watcher): isolate stale native watch generations Keep retained watcher handles, callbacks, and shims from pausing, draining, closing, purging, or injecting into a live native-watch generation. Acknowledge obsolete aggregates so delivery cannot stall and cover long-lived raw listeners, stale handles, and generation replacement. --- packages/rspack/src/NativeWatchFileSystem.ts | 31 ++++- .../NativeWatcherGeneration.test.js | 123 ++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/packages/rspack/src/NativeWatchFileSystem.ts b/packages/rspack/src/NativeWatchFileSystem.ts index 5d74250b7a87..17cc35730ae2 100644 --- a/packages/rspack/src/NativeWatchFileSystem.ts +++ b/packages/rspack/src/NativeWatchFileSystem.ts @@ -169,9 +169,11 @@ export default class NativeWatchFileSystem implements WatchFileSystem { // Fresh shim per cycle (see field comment). Events are emitted to both the // long-lived `#events` (the `on`/`once` API) and this cycle's shim (the // `.watcher` surface). - const watcher = new NativeWatcherShim((kind, path) => - this.#inner?.triggerEvent(kind, path), - ); + const watcher = new NativeWatcherShim((kind, path) => { + if (this.#watcher === watcher) { + nativeWatcher.triggerEvent(kind, path); + } + }); this.#watcher = watcher; let lastDrainedGeneration: number | undefined; @@ -181,6 +183,12 @@ export default class NativeWatchFileSystem implements WatchFileSystem { this.formatWatchDependencies(missing), BigInt(startTime), (err: Error | null, result) => { + if (this.#watcher !== watcher) { + if (!err) { + nativeWatcher.acknowledgePendingEvents(result.generation); + } + return; + } if (err) { callback(err, new Map(), new Map(), new Set(), new Set()); return; @@ -232,9 +240,10 @@ export default class NativeWatchFileSystem implements WatchFileSystem { return { close: () => { - if (this.#watcher === watcher) { - this.#watcher = undefined; + if (this.#watcher !== watcher) { + return; } + this.#watcher = undefined; if (this.#inner === nativeWatcher) { this.#inner = undefined; this.#isFirstWatch = true; @@ -245,10 +254,20 @@ export default class NativeWatchFileSystem implements WatchFileSystem { }, pause: () => { - nativeWatcher.pause(); + if (this.#watcher === watcher) { + nativeWatcher.pause(); + } }, getInfo: () => { + if (this.#watcher !== watcher) { + return { + changes: new Set(), + removals: new Set(), + fileTimeInfoEntries: new Map(), + contextTimeInfoEntries: new Map(), + }; + } const { changedFiles, removedFiles, generation } = nativeWatcher.takePendingEvents(); lastDrainedGeneration = generation; diff --git a/tests/rspack-test/NativeWatcherGeneration.test.js b/tests/rspack-test/NativeWatcherGeneration.test.js index b6764fbfaa39..8b29eb3ba4cd 100644 --- a/tests/rspack-test/NativeWatcherGeneration.test.js +++ b/tests/rspack-test/NativeWatcherGeneration.test.js @@ -3,6 +3,9 @@ const { rspack } = require("@rspack/core"); class FakeNativeWatcher { acknowledgements = []; pauses = 0; + closes = 0; + drains = 0; + triggered = []; pendingDrain = { changedFiles: [], removedFiles: [], @@ -15,6 +18,7 @@ class FakeNativeWatcher { } takePendingEvents() { + this.drains++; return this.pendingDrain; } @@ -26,7 +30,12 @@ class FakeNativeWatcher { this.pauses++; } + triggerEvent(kind, path) { + this.triggered.push([kind, path]); + } + close() { + this.closes++; return Promise.resolve(); } } @@ -154,4 +163,118 @@ describe("NativeWatchFileSystem aggregate generations", () => { nativeWatcher.onRaw({ kind: "change", path: "/after-close" }); expect(fresh).toEqual(["/fresh"]); }); + + it("forwards concrete child events to long-lived watch-file-system listeners", () => { + const events = []; + const { nativeWatcher, watchFileSystem } = createWatcherHarness(); + watchFileSystem.on("change", (file, mtime) => + events.push(["change", file, mtime]) + ); + watchFileSystem.on("remove", file => events.push(["remove", file])); + const staleRaw = nativeWatcher.onRaw; + + const currentWatcher = watchFileSystem.watch( + dependencies(), + dependencies(), + dependencies(), + Date.now(), + {}, + () => {}, + () => {} + ); + + staleRaw({ kind: "change", path: "/src/stale.js" }); + staleRaw({ kind: "remove", path: "/src/stale.js" }); + nativeWatcher.onRaw({ kind: "change", path: "/src/created.js" }); + nativeWatcher.onRaw({ kind: "remove", path: "/src/created.js" }); + expect(events).toEqual([ + ["change", "/src/created.js", expect.any(Number)], + ["remove", "/src/created.js"] + ]); + + currentWatcher.close(); + nativeWatcher.onRaw({ kind: "change", path: "/src/after-close.js" }); + nativeWatcher.onRaw({ kind: "remove", path: "/src/after-close.js" }); + expect(events).toHaveLength(2); + }); + + it("prevents stale watcher handles and callbacks from affecting the live generation", () => { + const events = []; + const { + nativeWatcher, + purged, + callbackChanges, + watcher: staleWatcher, + watchFileSystem + } = createWatcherHarness(); + watchFileSystem.on("aggregated", (changes, removals) => + events.push([changes, removals]) + ); + const staleAggregate = nativeWatcher.onAggregate; + const staleShim = watchFileSystem.watcher; + + const currentChanges = []; + const currentWatcher = watchFileSystem.watch( + dependencies(), + dependencies(), + dependencies(), + Date.now(), + {}, + (_error, _fileTimes, _contextTimes, changes) => + currentChanges.push(changes), + () => {} + ); + + nativeWatcher.pendingDrain = { + changedFiles: ["/src/live.js"], + removedFiles: [], + generation: 2 + }; + staleWatcher.pause(); + expect(staleWatcher.getInfo()).toEqual({ + changes: new Set(), + removals: new Set(), + fileTimeInfoEntries: new Map(), + contextTimeInfoEntries: new Map() + }); + staleWatcher.close(); + staleShim._onChange("/src/stale.js"); + staleShim._onRemove("/src/stale.js"); + staleAggregate(null, { + changedFiles: ["/src/stale.js"], + removedFiles: [], + generation: 1 + }); + + expect(nativeWatcher.pauses).toBe(0); + expect(nativeWatcher.drains).toBe(0); + expect(nativeWatcher.closes).toBe(0); + expect(nativeWatcher.triggered).toEqual([]); + expect(nativeWatcher.acknowledgements).toEqual([1]); + expect(purged).toEqual([]); + expect(callbackChanges).toEqual([]); + expect(currentChanges).toEqual([]); + expect(events).toEqual([]); + + nativeWatcher.onAggregate(null, { + changedFiles: ["/src/live.js"], + removedFiles: [], + generation: 2 + }); + expect(nativeWatcher.pauses).toBe(1); + expect(nativeWatcher.acknowledgements).toEqual([1, 2]); + expect(purged).toEqual(["/src/live.js"]); + expect(callbackChanges).toEqual([]); + expect(currentChanges).toEqual([new Set(["/src/live.js"])]); + expect(events).toEqual([[new Set(["/src/live.js"]), new Set()]]); + watchFileSystem.watcher._onChange("/src/live.js"); + watchFileSystem.watcher._onRemove("/src/live.js"); + expect(nativeWatcher.triggered).toEqual([ + ["change", "/src/live.js"], + ["remove", "/src/live.js"] + ]); + + currentWatcher.close(); + expect(nativeWatcher.closes).toBe(1); + }); });