diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index aa910080cdc2..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 @@ -466,13 +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 - /** - * # 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. - */ + takePendingEvents(): NativeWatchResult + acknowledgePendingEvents(generation: number): void close(): Promise pause(): void } @@ -480,6 +475,7 @@ export declare class NativeWatcher { export declare class NativeWatchResult { changedFiles: Array removedFiles: Array + generation: number } 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_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/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 a26fa1fad13b..817eda2bd9c0 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}; @@ -131,6 +130,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, @@ -154,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)?; @@ -172,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>>, } @@ -388,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, @@ -396,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| { @@ -403,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| { @@ -528,6 +532,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_binding_api/src/native_watcher.rs b/crates/rspack_binding_api/src/native_watcher.rs index ae9cc99fe6ab..2e815997c810 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>; @@ -44,6 +47,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` @@ -55,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 { @@ -79,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), @@ -97,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.", )); @@ -109,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), @@ -122,10 +131,9 @@ impl NativeWatcher { Box::new(js_event_handler), Box::new(js_event_handler_undelayed), ) - .await - }); - Ok(()) - })?; + .await; + } + }); Ok(()) } @@ -139,57 +147,55 @@ 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.state.watcher.blocking_lock().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 + .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(()) - })?; + pub fn close<'env>(&self, env: &'env Env) -> napi::Result> { + self.state.closed.store(true, Ordering::Release); + let state = Arc::clone(&self.state); - promise.finally(|_env| { - drop(shared_reference); - Ok(()) + 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()))?; @@ -214,7 +220,7 @@ struct JsEventHandler { Status, true, true, - 1, + 0, >, } @@ -223,7 +229,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), @@ -231,24 +239,41 @@ 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, - ) { - let changed_files_vec: Vec = changed_files.into_iter().collect(); - let deleted_files_vec: Vec = deleted_files.into_iter().collect(); + generation: u32, + ) -> bool { 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, - ); + ) == Status::Ok + } +} + +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, + ) { + let _ = 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) } fn on_error(&self, error: rspack_error::Error) { @@ -262,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, + >, >, } @@ -278,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_core/src/artifacts/build_chunk_graph_artifact.rs b/crates/rspack_core/src/artifacts/build_chunk_graph_artifact.rs index c2984ceebab6..fcb2fb69c585 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}; @@ -8,9 +9,13 @@ use rustc_hash::FxHashMap as HashMap; use tracing::instrument; use crate::{ - ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation, - Logger, ModuleIdentifier, - build_chunk_graph::code_splitter::{CodeSplitter, DependenciesBlockIdentifier}, + ArtifactExt, ChunkByUkey, ChunkGraph, ChunkGroupByUkey, ChunkGroupKind, ChunkGroupUkey, + 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, + }, fast_set, incremental::{IncrementalPasses, Mutation}, }; @@ -26,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: Vec<(String, Vec)>, } impl BuildChunkGraphArtifact { @@ -54,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) @@ -72,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 @@ -86,92 +166,129 @@ 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); - }); - - '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() - { - res.push(m); - continue 'outer; - } - } - } - - res - }; + 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; + } - // 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; + 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; + } - all_runtimes.for_each(|modules| { - let Some(outgoings) = modules.get(&DependenciesBlockIdentifier::Module(module)) else { - return; + 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, }; - 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); + + if !same_group_options { + logger.log(format!( + "module async block options change detected: {module}" + )); + return false; } - }); + } - if miss_in_previous { - logger.log("new module detected, rebuilding chunk graph"); - return false; + 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); } - 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; + for block in std::iter::once(DependenciesBlockIdentifier::Module(module)).chain( + current_blocks + .iter() + .copied() + .map(DependenciesBlockIdentifier::AsyncDependenciesBlock), + ) { + let mut 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) + .peekable(); + + 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 + && !(matches!(block, DependenciesBlockIdentifier::Module(_)) + && outgoings.peek().is_none() + && self.chunk_graph.try_get_module_chunks(&module).is_some()) + { + 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) + .eq(outgoings) + { + logger.log(format!("module outgoings change detected: {module}")); + return false; + } } } @@ -200,6 +317,148 @@ 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, Vec<(String, Vec)>) { + 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.filename.clone_from(&entry.options.filename); + options.public_path.clone_from(&entry.options.public_path); + + let filename = 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); } } @@ -230,6 +489,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 @@ -257,6 +518,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(()) } @@ -278,6 +544,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_core/src/compilation/build_chunk_graph/code_splitter.rs b/crates/rspack_core/src/compilation/build_chunk_graph/code_splitter.rs index 6ad4452efc8f..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, @@ -307,7 +377,8 @@ pub(crate) struct CodeSplitter { prepared_connection_map: IdentifierMap, - prepared_blocks_map: DependenciesBlockIdentifierMap>, + pub(crate) prepared_blocks_map: + DependenciesBlockIdentifierMap>, } fn add_chunk_in_group( @@ -336,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, @@ -2361,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/compilation/mod.rs b/crates/rspack_core/src/compilation/mod.rs index 466b34317ff1..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, } } @@ -525,7 +528,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 +557,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 +586,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 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_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/crates/rspack_plugin_copy/src/lib.rs b/crates/rspack_plugin_copy/src/lib.rs index ac6f984b0bc4..317bbadcd147 100644 --- a/crates/rspack_plugin_copy/src/lib.rs +++ b/crates/rspack_plugin_copy/src/lib.rs @@ -10,13 +10,14 @@ 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, 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::FxDashSet; use sugar_path::SugarPath; +mod pattern_cache; + +use pattern_cache::CachedPatternResult; + #[derive(Debug)] pub struct CopyRspackPluginOptions { pub patterns: Vec, @@ -119,14 +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: Mutex>>, +} + +struct PendingPattern<'a> { + index: usize, + pattern: &'a CopyPattern, + cacheable: bool, + file_dependencies: FxDashSet, + context_dependencies: FxDashSet, + diagnostics: Arc>>, } static TEMPLATE_RE: LazyLock = @@ -142,7 +155,16 @@ fn normalize_glob_path_separators(path: &str) -> Cow<'_, str> { impl CopyRspackPlugin { pub fn new(patterns: Vec) -> Self { - Self::new_inner(patterns) + let pattern_cache = Mutex::new(vec![None; patterns.len()]); + Self::new_inner(patterns, pattern_cache) + } + + 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( @@ -167,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}'")); @@ -299,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, @@ -311,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!( @@ -344,6 +358,7 @@ impl CopyRspackPlugin { } else { filename.as_str().normalize().to_string_lossy().to_string() }; + let filename = normalize_glob_path_separators(&filename).into_owned(); Ok(Some(RunPatternResult { source_filename, @@ -352,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); @@ -413,11 +425,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")); @@ -450,7 +457,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 { @@ -469,6 +475,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 { @@ -484,32 +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 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 { @@ -526,28 +519,31 @@ impl CopyRspackPlugin { "CopyRspackPlugin Error".into(), format!("unable to locate '{glob_query}' glob"), )); + return Ok(None); } let output_path = &compilation.options.output.path; - let copied_result = join_all(entries.into_iter().map(|entry| async { - Self::analyze_every_entry( - entry, - pattern, - &context, - output_path, - from_type, - file_dependencies, - diagnostics.clone(), - compilation, - logger, - index, - ) + 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 - })) - .await - .into_iter() - .collect::>>()?; + .into_iter() + .collect::>>()?; if copied_result.is_empty() { if pattern.no_error_on_missing { @@ -565,7 +561,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 { @@ -597,41 +593,186 @@ impl CopyRspackPlugin { } } } + + 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), + ) + }); + + 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); + } + 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), + ); + } + + if let Some(permission_copy) = permission_copy { + permission_copies.push(permission_copy); + } + } + } + + 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 file_dependencies = FxDashSet::default(); - let context_dependencies = FxDashSet::default(); - let diagnostics = Arc::new(Mutex::new(Vec::new())); + 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 + && (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 + .modified_files + .iter() + .chain(compilation.removed_files.iter()) + .map(|changed| changed.as_ref()), + ) + { + 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 mut copied_result: Vec<(i32, RunPatternResult)> = - join_all(self.patterns.iter().enumerate().map(|(index, pattern)| { - CopyRspackPlugin::run_patter( - compilation, - pattern, + cache_counter.miss(); + *cached = None; + pending_patterns.push(PendingPattern { index, - &file_dependencies, - &context_dependencies, - diagnostics.clone(), + 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 = pending_patterns + .iter() + .map(|pending| { + CopyRspackPlugin::run_pattern( + compilation, + pending.pattern, + &pending.file_dependencies, + &pending.context_dependencies, + pending.diagnostics.clone(), &logger, ) - })) - .await - .into_iter() - .collect::>>()? - .into_iter() - .flatten() - .flat_map(|item| { - item - .into_iter() - .flatten() - .map(|item| (item.priority, item)) - .collect::>() }) - .collect(); + .collect::>() + .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; + } + }; + 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 `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, + }); + } + + 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"); @@ -641,60 +782,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(); - - 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.extend_diagnostics(diagnostics); - 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 @@ -743,26 +838,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..6b9b888656c0 --- /dev/null +++ b/crates/rspack_plugin_copy/src/pattern_cache.rs @@ -0,0 +1,63 @@ +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, + mut changed_paths: impl Iterator, + ) -> bool { + 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_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())); + } +} 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_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/crates/rspack_plugin_extract_css/src/plugin.rs b/crates/rspack_plugin_extract_css/src/plugin.rs index d90431ae8ea3..514f0ced0a17 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; @@ -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()); @@ -555,8 +556,11 @@ 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", + needs_full_hash, + }, SOURCE_TYPE[0], global, move |runtime_requirements| { @@ -574,6 +578,7 @@ async fn runtime_requirement_in_tree( } }) }, + *chunk_ukey, )), )); 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/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_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_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..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 @@ -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,12 @@ 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, + pub needs_full_hash: bool, +} + #[impl_runtime_module] pub struct GetChunkFilenameRuntimeModule { #[cacheable(with=Unsupported)] @@ -29,6 +35,8 @@ pub struct GetChunkFilenameRuntimeModule { all_chunks: GetChunkFilenameAllChunks, #[cacheable(with=Unsupported)] filename_for_chunk: GetFilenameForChunk, + chunk_ukey: ChunkUkey, + needs_full_hash: bool, } impl fmt::Debug for GetChunkFilenameRuntimeModule { @@ -40,6 +48,8 @@ impl fmt::Debug for GetChunkFilenameRuntimeModule { .field("source_type", &self.source_type) .field("global", &self.global) .field("all_chunks", &"...") + .field("chunk_ukey", &self.chunk_ukey) + .field("needs_full_hash", &self.needs_full_hash) .finish() } } @@ -52,38 +62,80 @@ 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, + 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(&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] impl RuntimeModule for GetChunkFilenameRuntimeModule { fn runtime_requirements( &self, - compilation: &Compilation, + _compilation: &Compilation, ) -> rspack_core::RuntimeModuleRuntimeRequirements { 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 self.needs_full_hash { RuntimeGlobals::GET_FULL_HASH } else { RuntimeGlobals::default() @@ -117,49 +169,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..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 @@ -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,46 @@ 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) { + module.hot.active = false; + 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..492f588f4900 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, @@ -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()); @@ -257,11 +290,16 @@ async fn runtime_requirements_in_tree( *chunk_ukey, GetChunkFilenameRuntimeModule::new( &compilation.runtime_template, - "javascript", - "javascript", + 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), - |_| 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 +309,7 @@ async fn runtime_requirements_in_tree( ) }) }, + *chunk_ukey, ) .boxed(), )); @@ -283,8 +322,11 @@ async fn runtime_requirements_in_tree( *chunk_ukey, GetChunkFilenameRuntimeModule::new( &compilation.runtime_template, - "css", - "css", + 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), |runtime_requirements| { @@ -300,6 +342,7 @@ async fn runtime_requirements_in_tree( .clone() }) }, + *chunk_ukey, ) .boxed(), )); @@ -538,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/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); + } } 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..2b00f6996daa 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; @@ -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,24 @@ 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. + 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_paths.contains(p.path.as_ref()) + && !p + .path + .as_ref() + .ancestors() + .any(|path| stale_recursive_paths.contains(path)) + }); for pattern in new_patterns { if self.watch_patterns.contains(&pattern) { @@ -133,13 +151,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 +231,249 @@ 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_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(); + 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(10); + let observed = loop { + if let Ok(events) = rx.try_recv() + && events + .aggregated() + .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(10); + let observed = loop { + if let Ok(events) = rx.try_recv() + && events + .aggregated() + .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/src/executor.rs b/crates/rspack_watcher/src/executor.rs index 0dd678373292..5a492ac240c9 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.aggregated() { + 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( @@ -210,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 => { @@ -239,9 +351,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 +368,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..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. @@ -52,6 +73,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 asynchronous delivery was + /// successfully queued; the caller must then acknowledge the generation. + 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 +198,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/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/pending_events.rs b/crates/rspack_watcher/tests/pending_events.rs new file mode 100644 index 000000000000..b9917ce6d9e7 --- /dev/null +++ b/crates/rspack_watcher/tests/pending_events.rs @@ -0,0 +1,278 @@ +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 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 { + 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"); +} + +#[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/crates/rspack_watcher/tests/watcher.rs b/crates/rspack_watcher/tests/watcher.rs index 22ce9effd1fb..07d57c0341a7 100644 --- a/crates/rspack_watcher/tests/watcher.rs +++ b/crates/rspack_watcher/tests/watcher.rs @@ -132,3 +132,93 @@ 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 created_events = c!(); + helper.collect_events( + rx, + |event, _| { + 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!(existing_events) > 0); + assert!(load!(created_events) > 0); +} 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/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..8eabb794f0e4 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; @@ -845,6 +848,7 @@ class Compiler { Array.from(this.modifiedFiles || []), Array.from(this.removedFiles || []), callback, + this.__internal__watchInvalidationKind === 'lazy', ); return; } diff --git a/packages/rspack/src/MultiCompiler.ts b/packages/rspack/src/MultiCompiler.ts index de6653cae949..ba85b042e3ec 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,13 @@ export class MultiCompiler { running--; if (node.state === 'running') { node.state = 'done'; + const invalidationKind = stats.compilation.watchInvalidationKind; for (const child of node.children) { + // 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); } } else if (node.state === 'running-outdated') { @@ -422,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); @@ -476,7 +490,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 +547,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..17cc35730ae2 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 @@ -93,6 +100,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; @@ -149,10 +169,13 @@ 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; nativeWatcher.watch( this.formatWatchDependencies(files), @@ -160,22 +183,28 @@ 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; } + if ( + lastDrainedGeneration !== undefined && + !isNewerGeneration(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); @@ -190,6 +219,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. @@ -208,27 +240,41 @@ 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.#watcher !== watcher) { + return; + } + this.#watcher = undefined; + if (this.#inner === nativeWatcher) { + this.#inner = undefined; + this.#isFirstWatch = true; + } + nativeWatcher.close().catch((err: unknown) => { + console.error('Error closing native watcher:', err); + }); }, pause: () => { - nativeWatcher.pause(); + if (this.#watcher === watcher) { + nativeWatcher.pause(); + } }, - getInfo() { - // This is a placeholder implementation. - // TODO: The actual implementation should return the current state of the watcher. + 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; + 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..ababdb28987c 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -11,11 +11,19 @@ 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'; 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( @@ -53,6 +61,7 @@ export class Watching { #closed: boolean; #collectedChangedFiles?: Set; #collectedRemovedFiles?: Set; + #pendingInvalidationKind?: WatchInvalidationKind; #pendingWatchDeps?: { file: PendingWatchDelta; context: PendingWatchDelta; @@ -131,6 +140,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 +152,13 @@ export class Watching { this.onChange(); }, (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); @@ -159,6 +178,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 +235,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 +284,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); } @@ -255,6 +300,7 @@ export class Watching { if (this.suspended || (this.isBlocked() && (this.blocked = true))) { return; } + this.blocked = false; if (this.running) { this.invalid = true; @@ -300,13 +346,13 @@ export class Watching { this.compiler.fileTimestamps = fileTimeInfoEntries; this.compiler.contextTimestamps = contextTimeInfoEntries; } else if (this.pausedWatcher) { - const { changes, removals, fileTimeInfoEntries, contextTimeInfoEntries } = - this.pausedWatcher.getInfo(); - this.#mergeWithCollected(changes, removals); - this.compiler.fileTimestamps = fileTimeInfoEntries; - this.compiler.contextTimestamps = contextTimeInfoEntries; + this.#drainPausedWatcher(); } + this.compiler.__internal__watchInvalidationKind = + this.#pendingInvalidationKind; + this.#pendingInvalidationKind = undefined; + this.compiler.modifiedFiles = this.#collectedChangedFiles; this.compiler.removedFiles = this.#collectedRemovedFiles; this.#collectedChangedFiles = undefined; @@ -394,6 +440,8 @@ export class Watching { }; if (error) { + this.#pendingInvalidationKind = undefined; + this.compiler.__internal__watchInvalidationKind = undefined; return handleError(error); } @@ -403,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 && @@ -411,7 +474,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,46 +489,33 @@ 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); + + // Snapshot this build's watch deltas before user callbacks can invalidate. + this.#accumulateWatchDeps(compilation); + const pending = this.#pendingWatchDeps!; + this.#pendingWatchDeps = undefined; + + 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); 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, @@ -475,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/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/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts b/packages/rspack/src/builtin-plugin/lazy-compilation/middleware.ts index 28895e448504..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?.(); } @@ -261,7 +263,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/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/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/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/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); + } + } + ); +}); diff --git a/tests/rspack-test/CopyPluginCache.test.js b/tests/rspack-test/CopyPluginCache.test.js new file mode 100644 index 000000000000..2fbbaf3c2b7e --- /dev/null +++ b/tests/rspack-test/CopyPluginCache.test.js @@ -0,0 +1,692 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + CopyRspackPlugin, + Stats, + lazyCompilationMiddleware, + 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 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 = []; + 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())); + }); + }, + invalidate() { + watching.invalidate(); + } + }; +} + +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; + + 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 || []) + .map(entry => entry.message || "") + .map(message => message.match(/^found '(.+)'$/)?.[1]) + .filter(filename => + filename?.replaceAll("\\", "/").includes(normalizedDirectory) + ); +} + +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], + ...(process.env.WASM ? [] : [["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/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 { + 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]); + + 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, "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); + } + }); + + 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); + } + }); + + 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: `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" } + ]); + 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); + } + }); + + 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); + } + }); +}); 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); + } + }); +}); diff --git a/tests/rspack-test/LazyCompilationArtifactChain.test.js b/tests/rspack-test/LazyCompilationArtifactChain.test.js new file mode 100644 index 000000000000..6d0d6ad73c1d --- /dev/null +++ b/tests/rspack-test/LazyCompilationArtifactChain.test.js @@ -0,0 +1,381 @@ +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); + } +} + +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-")); + 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 releaseSource; + 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' + ); + // 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, + dependencies: parent[name] ? [parent[name]] : [], + devtool: false, + entry: + name === "source" + ? { main: "./source.js", coalesced: "./coalesced-source.js" } + : { main: `./${name}.js` }, + experiments: { nativeWatcher }, + 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 => { + invalidations.push({ name, file }); + if (name === "source" && path.basename(file ?? "") === "source.js") { + 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; + 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"); + const beforeFileInvalidations = invalidations.length; + await fs.writeFile( + sourceFile, + 'export const source = "source";\nexport const edited = "file-edit";\n' + ); + if (nativeWatcher) { + await withTimeout(fileInvalidated, "the coalesced source file event"); + } else { + await waitForPausedChange(watching, "source"); + } + 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, + `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(); + const deliveredGenerations = { ...generations }; + 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())) + ); + } + if (server) await new Promise(resolve => server.close(resolve)); + await fs.rm(root, { force: true, recursive: true }); + } + } + + it.each([ + ...(process.env.WASM ? [] : [["native", true]]), + ["watchpack", false] + ])( + "keeps dependent and coalesced file-backed generations normal with %s watching", + runArtifactChain + ); +}); 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 }); + } + }); +}); diff --git a/tests/rspack-test/NativeWatcherGeneration.test.js b/tests/rspack-test/NativeWatcherGeneration.test.js new file mode 100644 index 000000000000..8b29eb3ba4cd --- /dev/null +++ b/tests/rspack-test/NativeWatcherGeneration.test.js @@ -0,0 +1,280 @@ +const { rspack } = require("@rspack/core"); + +class FakeNativeWatcher { + acknowledgements = []; + pauses = 0; + closes = 0; + drains = 0; + triggered = []; + pendingDrain = { + changedFiles: [], + removedFiles: [], + generation: 0 + }; + + watch(_files, _directories, _missing, _startTime, onAggregate, onRaw) { + this.onAggregate = onAggregate; + this.onRaw = onRaw; + } + + takePendingEvents() { + this.drains++; + return this.pendingDrain; + } + + acknowledgePendingEvents(generation) { + this.acknowledgements.push(generation); + } + + pause() { + this.pauses++; + } + + triggerEvent(kind, path) { + this.triggered.push([kind, path]); + } + + close() { + this.closes++; + return Promise.resolve(); + } +} + +const dependencies = () => + Object.assign([], { + added: [], + removed: [] + }); + +function createWatcherHarness(callbackUndelayed = () => {}) { + 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), + callbackUndelayed + ); + + return { nativeWatcher, purged, callbackChanges, watcher, watchFileSystem }; +} + +describe("NativeWatchFileSystem aggregate generations", () => { + it("suppresses a callback superseded by a synchronous native drain", () => { + const { nativeWatcher, purged, callbackChanges, watcher } = + createWatcherHarness(); + + 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"])]); + }); + + 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"])]); + }); + + 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"]); + }); + + 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); + }); +}); 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/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([]); + }); +}); 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/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())); + }); + } + }); +}); 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 }); + } + }); +}); 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/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() { 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/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)); } 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..90d2da8235ad --- /dev/null +++ b/tests/rspack-test/hotCases/chunks/initial-chunks-hmr/__snapshots__/web/1.snap.txt @@ -0,0 +1,781 @@ +# 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: 21546 + +## 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) { + module.hot.active = false; + 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..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: 19242 +- Update: runtime~main.LAST_HASH.hot-update.js, size: 20922 - 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,46 @@ 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) { + module.hot.active = false; + 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/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(); + } }); })(); } 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..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: 18632 +- Update: main.LAST_HASH.hot-update.js, size: 20256 ## 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,46 @@ 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) { + module.hot.active = false; + 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..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: 18632 +- Update: main.LAST_HASH.hot-update.js, size: 20256 ## 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,46 @@ 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) { + module.hot.active = false; + 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..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: 15545 +- Update: main.LAST_HASH.hot-update.js, size: 17169 ## 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,46 @@ 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) { + module.hot.active = false; + 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..9676f7e853b6 --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/a.js @@ -0,0 +1,5 @@ +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/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..1ce7004637fe --- /dev/null +++ b/tests/rspack-test/hotCases/runtime/preserve-disposed-module-factories/index.js @@ -0,0 +1,61 @@ +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; +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; + } +}); + +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 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)"); + } 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..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: 42679 +- Update: main.LAST_HASH.hot-update.js, size: 44303 ## 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,46 @@ 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) { + module.hot.active = false; + 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..a869718c08da 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,46 @@ module.exports = [ a done, ] `); - events.length = 0; - expect(state).toBe(1); - setTimeout(() => { - compiler.close(resolve); - }, 1000); - } catch (e) { - console.error(e); - reject(e); - } - }); + // `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; + 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, + "normal", + "normal" + ]); + state = 3; + compiler.close(error => (error ? reject(error) : resolve())); + } catch (error) { + compiler.close(() => reject(error)); + } }); }); } 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:*", 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..89c0327fb04d --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/index.js @@ -0,0 +1,41 @@ +import { value, load, sync } from "./route"; + +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/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..3117840fbabe --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/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: "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 new file mode 100644 index 000000000000..04b6e0975cb5 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/side-effect.js @@ -0,0 +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 new file mode 100644 index 000000000000..b01d7717db87 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-first.js @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000000..7f5a637dd844 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/0/sync-second.js @@ -0,0 +1 @@ +export const second = (globalThis.__codesplit_order__.push("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..d4f66899c70d --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/2/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" */ "./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 new file mode 100644 index 000000000000..e4e3a61a12ef --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/rspack.config.js @@ -0,0 +1,19 @@ +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + output: { + filename: 'bundle.js', + chunkFilename: '[name].bundle.js', + }, + 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..324ec1d58262 --- /dev/null +++ b/tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/test.config.js @@ -0,0 +1,50 @@ +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 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}`); + } + return true; + }, +}; 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"; 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)):可接受模块的通知者。