diff --git a/Cargo.lock b/Cargo.lock index 5e1ec3562c3..d226018a96f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3737,6 +3737,7 @@ dependencies = [ "rspack_paths", "rspack_plugin_asset", "rspack_plugin_banner", + "rspack_plugin_circular_dependencies", "rspack_plugin_context_replacement", "rspack_plugin_copy", "rspack_plugin_css", @@ -4207,6 +4208,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "rspack_plugin_circular_dependencies" +version = "0.2.0" +dependencies = [ + "derive_more", + "itertools 0.13.0", + "rspack_collections", + "rspack_core", + "rspack_error", + "rspack_hook", + "rspack_regex", + "tracing", +] + [[package]] name = "rspack_plugin_context_replacement" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 87dc583b462..96ffc1f6a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ rspack_napi_macros = { version = "0.2.0", path = "crates/rsp rspack_paths = { version = "0.2.0", path = "crates/rspack_paths" } rspack_plugin_asset = { version = "0.2.0", path = "crates/rspack_plugin_asset" } rspack_plugin_banner = { version = "0.2.0", path = "crates/rspack_plugin_banner" } +rspack_plugin_circular_dependencies = { version = "0.2.0", path = "crates/rspack_plugin_circular_dependencies" } rspack_plugin_context_replacement = { version = "0.2.0", path = "crates/rspack_plugin_context_replacement" } rspack_plugin_copy = { version = "0.2.0", path = "crates/rspack_plugin_copy" } rspack_plugin_css = { version = "0.2.0", path = "crates/rspack_plugin_css" } diff --git a/crates/node_binding/binding.d.ts b/crates/node_binding/binding.d.ts index 6dd61c6f44b..be848a84c14 100644 --- a/crates/node_binding/binding.d.ts +++ b/crates/node_binding/binding.d.ts @@ -363,6 +363,7 @@ export declare enum BuiltinPluginName { LightningCssMinimizerRspackPlugin = 'LightningCssMinimizerRspackPlugin', BundlerInfoRspackPlugin = 'BundlerInfoRspackPlugin', CssExtractRspackPlugin = 'CssExtractRspackPlugin', + CircularDependencyRspackPlugin = 'CircularDependencyRspackPlugin', JsLoaderRspackPlugin = 'JsLoaderRspackPlugin', LazyCompilationPlugin = 'LazyCompilationPlugin' } @@ -1211,6 +1212,17 @@ export interface RawCacheOptions { type: string } +export interface RawCircularDependencyRspackPluginOptions { + failOnError?: boolean + allowAsyncCycles?: boolean + exclude?: RspackRegex + ignoredConnections?: Array<[ConnectionPattern, ConnectionPattern]> + onDetected?: (entrypoint: Module, modules: string[], compilation: Compilation) => void + onIgnored?: (entrypoint: Module, modules: string[], compilation: Compilation) => void + onStart?: (compilation: Compilation) => void + onEnd?: (compilation: Compilation) => void +} + export interface RawConsumeOptions { key: string import?: string diff --git a/crates/rspack_binding_values/Cargo.toml b/crates/rspack_binding_values/Cargo.toml index 5e2e48e2779..8eaa501c0b7 100644 --- a/crates/rspack_binding_values/Cargo.toml +++ b/crates/rspack_binding_values/Cargo.toml @@ -49,6 +49,7 @@ rspack_loader_swc = { workspace = true } rspack_loader_testing = { workspace = true } rspack_plugin_asset = { workspace = true } rspack_plugin_banner = { workspace = true } +rspack_plugin_circular_dependencies = { workspace = true } rspack_plugin_context_replacement = { workspace = true } rspack_plugin_copy = { workspace = true } rspack_plugin_css = { workspace = true } diff --git a/crates/rspack_binding_values/src/raw_options/raw_builtins/mod.rs b/crates/rspack_binding_values/src/raw_options/raw_builtins/mod.rs index f9cac94c2ba..ec248015f1e 100644 --- a/crates/rspack_binding_values/src/raw_options/raw_builtins/mod.rs +++ b/crates/rspack_binding_values/src/raw_options/raw_builtins/mod.rs @@ -1,5 +1,6 @@ mod raw_banner; mod raw_bundle_info; +mod raw_circular_dependency; mod raw_copy; mod raw_css_extract; mod raw_dll; @@ -29,6 +30,7 @@ use rspack_ids::{ use rspack_napi::NapiResultExt; use rspack_plugin_asset::AssetPlugin; use rspack_plugin_banner::BannerPlugin; +use rspack_plugin_circular_dependencies::CircularDependencyRspackPlugin; use rspack_plugin_context_replacement::ContextReplacementPlugin; use rspack_plugin_copy::{CopyRspackPlugin, CopyRspackPluginOptions}; use rspack_plugin_css::CssPlugin; @@ -85,6 +87,7 @@ use rspack_plugin_worker::WorkerPlugin; pub use self::{ raw_banner::RawBannerPluginOptions, + raw_circular_dependency::RawCircularDependencyRspackPluginOptions, raw_copy::RawCopyRspackPluginOptions, raw_dll::{RawDllEntryPluginOptions, RawLibManifestPluginOptions}, raw_html::RawHtmlRspackPluginOptions, @@ -192,6 +195,7 @@ pub enum BuiltinPluginName { LightningCssMinimizerRspackPlugin, BundlerInfoRspackPlugin, CssExtractRspackPlugin, + CircularDependencyRspackPlugin, // rspack js adapter plugins // naming format follow XxxRspackPlugin @@ -514,6 +518,12 @@ impl BuiltinPlugin { .boxed(); plugins.push(plugin); } + BuiltinPluginName::CircularDependencyRspackPlugin => plugins.push( + CircularDependencyRspackPlugin::new( + downcast_into::(self.options)?.into(), + ) + .boxed(), + ), BuiltinPluginName::JsLoaderRspackPlugin => { plugins .push(JsLoaderRspackPlugin::new(downcast_into::(self.options)?).boxed()); diff --git a/crates/rspack_binding_values/src/raw_options/raw_builtins/raw_circular_dependency.rs b/crates/rspack_binding_values/src/raw_options/raw_builtins/raw_circular_dependency.rs new file mode 100644 index 00000000000..300fcdc1e51 --- /dev/null +++ b/crates/rspack_binding_values/src/raw_options/raw_builtins/raw_circular_dependency.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use napi::{Either, JsUnknown}; +use napi_derive::napi; +use rspack_napi::threadsafe_function::ThreadsafeFunction; +use rspack_plugin_circular_dependencies::{ + CircularDependencyIgnoredConnection, CircularDependencyIgnoredConnectionEntry, + CircularDependencyRspackPluginOptions, CompilationHookFn, CycleHandlerFn, +}; +use rspack_regex::RspackRegex; + +use crate::JsCompilationWrapper; + +fn ignore_pattern_to_entry( + pattern: Either, +) -> CircularDependencyIgnoredConnectionEntry { + match pattern { + Either::A(string) => CircularDependencyIgnoredConnectionEntry::String(string), + Either::B(pattern) => CircularDependencyIgnoredConnectionEntry::Pattern(pattern), + } +} + +type ConnectionPattern = Either; +type CycleHookParams = (String, Vec, JsCompilationWrapper); + +#[derive(Debug)] +#[napi(object, object_to_js = false)] +pub struct RawCircularDependencyRspackPluginOptions { + pub fail_on_error: Option, + pub allow_async_cycles: Option, + pub exclude: Option, + pub ignored_connections: Option>, + #[napi(ts_type = "(entrypoint: Module, modules: string[], compilation: Compilation) => void")] + pub on_detected: Option>, + #[napi(ts_type = "(entrypoint: Module, modules: string[], compilation: Compilation) => void")] + pub on_ignored: Option>, + #[napi(ts_type = "(compilation: Compilation) => void")] + pub on_start: Option>, + #[napi(ts_type = "(compilation: Compilation) => void")] + pub on_end: Option>, +} + +impl From for CircularDependencyRspackPluginOptions { + fn from(value: RawCircularDependencyRspackPluginOptions) -> Self { + // This explicit cast is needed because Rust otherwise infers an incompatible type + // for the closure compared to the field in the options object. + let on_detected: Option = match value.on_detected { + Some(callback) => Some(Arc::new(move |entrypoint, modules, compilation| { + callback.blocking_call_with_sync(( + entrypoint, + modules, + JsCompilationWrapper::new(compilation), + ))?; + Ok(()) + })), + _ => None, + }; + let on_ignored: Option = match value.on_ignored { + Some(callback) => Some(Arc::new(move |entrypoint, modules, compilation| { + callback.blocking_call_with_sync(( + entrypoint, + modules, + JsCompilationWrapper::new(compilation), + ))?; + Ok(()) + })), + _ => None, + }; + let on_start: Option = match value.on_start { + Some(callback) => Some(Arc::new(move |compilation| { + callback.blocking_call_with_sync(JsCompilationWrapper::new(compilation))?; + Ok(()) + })), + _ => None, + }; + let on_end: Option = match value.on_end { + Some(callback) => Some(Arc::new(move |compilation| { + callback.blocking_call_with_sync(JsCompilationWrapper::new(compilation))?; + Ok(()) + })), + _ => None, + }; + + Self { + fail_on_error: value.fail_on_error.unwrap_or(false), + allow_async_cycles: value.allow_async_cycles.unwrap_or(false), + exclude: value.exclude, + ignored_connections: value.ignored_connections.map(|connections| { + connections + .into_iter() + .map(|(from, to)| { + CircularDependencyIgnoredConnection( + ignore_pattern_to_entry(from), + ignore_pattern_to_entry(to), + ) + }) + .collect() + }), + on_detected, + on_ignored, + on_start, + on_end, + } + } +} diff --git a/crates/rspack_plugin_circular_dependencies/Cargo.toml b/crates/rspack_plugin_circular_dependencies/Cargo.toml new file mode 100644 index 00000000000..6f7d33fba46 --- /dev/null +++ b/crates/rspack_plugin_circular_dependencies/Cargo.toml @@ -0,0 +1,20 @@ +[package] +description = "rspack circular dependency detection plugin" +edition = "2021" +license = "MIT" +name = "rspack_plugin_circular_dependencies" +repository = "https://github.com/web-infra-dev/rspack" +version = "0.2.0" + +[dependencies] +derive_more = { workspace = true, features = ["debug"] } +itertools = { workspace = true } +rspack_collections = { workspace = true } +rspack_core = { workspace = true } +rspack_error = { workspace = true } +rspack_hook = { workspace = true } +rspack_regex = { workspace = true } +tracing = { workspace = true } + +[package.metadata.cargo-shear] +ignored = ["tracing"] diff --git a/crates/rspack_plugin_circular_dependencies/src/lib.rs b/crates/rspack_plugin_circular_dependencies/src/lib.rs new file mode 100644 index 00000000000..e56d20d86d9 --- /dev/null +++ b/crates/rspack_plugin_circular_dependencies/src/lib.rs @@ -0,0 +1,385 @@ +#![feature(array_windows)] + +use std::sync::Arc; + +use derive_more::Debug; +use itertools::Itertools; +use rspack_collections::{Identifier, IdentifierMap, IdentifierSet}; +use rspack_core::{ + ApplyContext, Compilation, CompilerOptions, ModuleIdentifier, Plugin, PluginContext, +}; +use rspack_core::{CompilationOptimizeModules, DependencyType}; +use rspack_error::{Diagnostic, Result}; +use rspack_hook::{plugin, plugin_hook}; +use rspack_regex::RspackRegex; + +struct CycleDetector<'a> { + module_map: &'a IdentifierMap, +} + +impl<'a> CycleDetector<'a> { + fn new(module_map: &'a IdentifierMap) -> Self { + Self { module_map } + } + + fn get_module(&self, id: &ModuleIdentifier) -> &GraphModule { + self + .module_map + .get(id) + .expect("Module map should only contain references to existing modules") + } + + /// Returns all dependency cycles contained in the dependency graph starting at + /// `initial_module_id`. + fn find_cycles_from( + &mut self, + initial_module_id: ModuleIdentifier, + ) -> Vec> { + let mut cycles = vec![]; + self.recurse_dependencies( + initial_module_id, + &mut IdentifierSet::default(), + &mut vec![initial_module_id], + &mut cycles, + ); + cycles + } + + fn recurse_dependencies( + &self, + current_module_id: ModuleIdentifier, + seen_set: &mut IdentifierSet, + current_path: &mut Vec, + found_cycles: &mut Vec>, + ) { + seen_set.insert(current_module_id); + current_path.push(current_module_id); + for target_id in self.get_module(¤t_module_id).dependencies.keys() { + // If the current path already contains the dependent module, then it + // creates a cycle and doesn't need to be traversed further. Otherwise, + // recurse through that dependency to keep searching. + // + // It's possible for this cycle to connect back at any point in the + // current path, so the recorded cycle is split at that position to only + // include the modules involved in the cycle. + if let Some(cycle_start) = current_path + .iter() + .rposition(|element| element == target_id) + { + let mut cycle = current_path[cycle_start..].to_vec(); + cycle.push(*target_id); + found_cycles.push(cycle); + continue; + } + + // If a module has already been encountered in this traversal, then by + // necessity it is either already part of a cycle being detected as + // captured above, or it _and all of its dependencies_ are not part of + // any cycles involving the current module. If that were not true, then + // this module would have already been encountered previously. + if seen_set.contains(target_id) { + continue; + } + + self.recurse_dependencies(*target_id, seen_set, current_path, found_cycles); + } + current_path.pop(); + } +} + +/// Single Dependency representing all types of connections to a target module. +#[derive(Debug)] +struct AggregatedDependency { + #[allow(unused)] + target_id: ModuleIdentifier, + types: Vec, +} + +impl AggregatedDependency { + fn new(target_id: ModuleIdentifier, types: Vec) -> Self { + Self { target_id, types } + } + + /// Returns true if _every_ type of this dependency is dynamic, meaning there + /// are _no_ static connections that would cause a cycle. + fn is_asynchronous_only(&self) -> bool { + self.types.iter().all(|ty| { + matches!( + ty, + // This list of types is made purely by intuition, since dynamic + // dependencies are not the same as weak or "async" dependencies in + // the context of circular detection. + DependencyType::DynamicImport + | DependencyType::DynamicImportEager + | DependencyType::LazyImport + | DependencyType::ImportMetaHotAccept + | DependencyType::ImportMetaHotDecline + | DependencyType::ModuleHotAccept + | DependencyType::ModuleHotDecline + | DependencyType::RequireResolve + ) + }) + } +} + +#[derive(Debug)] +struct GraphModule { + #[allow(unused)] + id: Identifier, + is_source: bool, + dependencies: IdentifierMap, +} + +impl GraphModule { + fn new(id: ModuleIdentifier, is_source: bool) -> Self { + Self { + id, + is_source, + dependencies: IdentifierMap::default(), + } + } + + fn add_dependency(&mut self, target_id: ModuleIdentifier, ty: DependencyType) { + self + .dependencies + .entry(target_id) + .and_modify(|dep| dep.types.push(ty)) + .or_insert_with(|| AggregatedDependency::new(target_id, vec![ty])); + } +} + +fn build_module_map(compilation: &Compilation) -> IdentifierMap { + let module_graph = compilation.get_module_graph(); + let modules = module_graph.modules(); + + let mut module_map: IdentifierMap = IdentifierMap::default(); + module_map.reserve(modules.len()); + for (id, module) in modules { + let mut graph_module = GraphModule::new(id, module.original_source().is_some()); + for dependency_id in module.get_dependencies() { + let Some(dependency) = module_graph.dependency_by_id(dependency_id) else { + continue; + }; + let Some(dependent_module) = module_graph.get_module_by_dependency_id(dependency_id) else { + continue; + }; + // Only include dependencies that represent a real source file. + if dependent_module.original_source().is_none() { + continue; + } + // Self dependencies are added in various ways, but don't mean anything here. + if dependent_module.identifier() == id { + continue; + } + graph_module.add_dependency(dependent_module.identifier(), *dependency.dependency_type()); + } + + module_map.insert(id, graph_module); + } + module_map +} + +#[derive(Debug)] +pub enum CircularDependencyIgnoredConnectionEntry { + String(String), + Pattern(RspackRegex), +} + +impl CircularDependencyIgnoredConnectionEntry { + pub fn test(&self, value: &str) -> bool { + match self { + Self::String(string) => value.contains(string), + Self::Pattern(pattern) => pattern.test(value), + } + } +} + +#[derive(Debug)] +pub struct CircularDependencyIgnoredConnection( + pub CircularDependencyIgnoredConnectionEntry, + pub CircularDependencyIgnoredConnectionEntry, +); + +impl CircularDependencyIgnoredConnection { + /// Returns true if the given connection should be ignored according to this + /// instance. If either side of this connection is [None], only the other + /// side is required to match for it to be ignored. + pub fn is_ignored(&self, from: &str, to: &str) -> bool { + self.0.test(from) && self.1.test(to) + } +} + +pub type CycleHandlerFn = + Arc, &mut Compilation) -> Result<()> + Send + Sync>; +pub type CompilationHookFn = Arc Result<()> + Send + Sync>; + +#[derive(Debug)] +pub struct CircularDependencyRspackPluginOptions { + /// When `true`, the plugin will emit Error diagnostics rather than the + /// default Warn severity. + pub fail_on_error: bool, + /// When `true`, asynchronous imports like `import("some-module")` will not + /// be considered connections that can create cycles. + pub allow_async_cycles: bool, + /// Cycles containing any module name that matches this regex will not be + /// counted as a cycle. + pub exclude: Option, + /// List of dependency connections that should not count for creating cycles. + /// Connections are represented as `[from, to]`, where each entry must be an + /// exact match for the module path. + pub ignored_connections: Option>, + /// Handler function called for every detected cycle. Providing this handler + /// overrides the default behavior of adding diagnostics to the compilation. + #[debug(skip)] + pub on_detected: Option, + #[debug(skip)] + pub on_ignored: Option, + #[debug(skip)] + pub on_start: Option, + #[debug(skip)] + pub on_end: Option, +} + +#[plugin] +#[derive(Debug)] +pub struct CircularDependencyRspackPlugin { + options: CircularDependencyRspackPluginOptions, +} + +impl CircularDependencyRspackPlugin { + pub fn new(options: CircularDependencyRspackPluginOptions) -> Self { + Self::new_inner(options) + } + + fn is_ignored_module(&self, name: &str) -> bool { + match &self.options.exclude { + Some(pattern) => pattern.test(name), + None => false, + } + } + + fn is_ignored_connection(&self, from: &str, to: &str) -> bool { + match &self.options.ignored_connections { + Some(ignored_connections) => ignored_connections + .iter() + .any(|connection| connection.is_ignored(from, to)), + None => false, + } + } + + fn is_cycle_ignored( + &self, + module_map: &IdentifierMap, + cycle: &[ModuleIdentifier], + ) -> bool { + for [module_id, target_id] in cycle.array_windows::<2>() { + // If any dependency in the cycle is purely asynchronous, then it does not count as a runtime + // circular dependency, since execution order will be guaranteed. + if module_map[module_id].dependencies[target_id].is_asynchronous_only() { + return true; + } + + // Not all cycles are errors, so filter out any cycles containing + // explicitly-ignored modules. + if self.is_ignored_module(module_id) || self.is_ignored_connection(module_id, target_id) { + return true; + } + } + false + } + + fn handle_cycle_ignored( + &self, + entrypoint: String, + cycle: Vec, + compilation: &mut Compilation, + ) -> Result<()> { + match &self.options.on_ignored { + Some(callback) => callback( + entrypoint, + cycle.iter().map(ToString::to_string).collect(), + compilation, + ), + _ => Ok(()), + } + } + + fn handle_cycle_detected( + &self, + entrypoint: String, + cycle: Vec, + compilation: &mut Compilation, + ) -> Result<()> { + if let Some(callback) = &self.options.on_detected { + return callback( + entrypoint, + cycle.iter().map(ToString::to_string).collect(), + compilation, + ); + } + + let diagnostic_factory = if self.options.fail_on_error { + Diagnostic::error + } else { + Diagnostic::warn + }; + + compilation.push_diagnostic(diagnostic_factory( + "Circular Dependency".to_string(), + cycle.iter().join(" -> "), + )); + Ok(()) + } +} + +#[plugin_hook(CompilationOptimizeModules for CircularDependencyRspackPlugin)] +async fn optimize_modules(&self, compilation: &mut Compilation) -> Result> { + if let Some(on_start) = &self.options.on_start { + on_start(compilation)?; + }; + + let module_map = build_module_map(compilation); + let mut detector = CycleDetector::new(&module_map); + for (entrypoint_name, chunk_group_key) in compilation.entrypoints.clone() { + let chunk_group = compilation + .chunk_group_by_ukey + .get(&chunk_group_key) + .expect("Compilation should contain entrypoint chunk groups"); + let entry_modules = compilation + .chunk_graph + .get_chunk_entry_modules(&chunk_group.get_entry_point_chunk()); + + for module_id in entry_modules { + // Only consider entrypoint modules coming from existing source code. + // This skips internal things like runtime and generated chunks. + if !detector.get_module(&module_id).is_source { + continue; + }; + + for cycle in detector.find_cycles_from(module_id) { + if self.is_cycle_ignored(&module_map, &cycle) { + self.handle_cycle_ignored(entrypoint_name.clone(), cycle, compilation)? + } else { + self.handle_cycle_detected(entrypoint_name.clone(), cycle, compilation)? + } + } + } + } + + if let Some(on_end) = &self.options.on_end { + on_end(compilation)?; + } + Ok(None) +} + +// implement apply method for the plugin +impl Plugin for CircularDependencyRspackPlugin { + fn apply(&self, ctx: PluginContext<&mut ApplyContext>, _options: &CompilerOptions) -> Result<()> { + ctx + .context + .compilation_hooks + .optimize_modules + .tap(optimize_modules::new(self)); + Ok(()) + } +} diff --git a/package.json b/package.json index 5b743de13d4..941ef0d557f 100644 --- a/package.json +++ b/package.json @@ -99,4 +99,4 @@ ], "package.json": "pnpm run check-dependency-version" } -} \ No newline at end of file +} diff --git a/packages/rspack/src/builtin-plugin/CircularDependencyRspackPlugin.ts b/packages/rspack/src/builtin-plugin/CircularDependencyRspackPlugin.ts new file mode 100644 index 00000000000..43a7edc8814 --- /dev/null +++ b/packages/rspack/src/builtin-plugin/CircularDependencyRspackPlugin.ts @@ -0,0 +1,83 @@ +import { + BuiltinPluginName, + type RawCircularDependencyRspackPluginOptions +} from "@rspack/binding"; +import type { Compilation } from "../Compilation"; +import type { Module } from "../Module"; +import { create } from "./base"; + +export type CircularDependencyRspackPluginOptions = { + /** + * When `true`, the plugin will emit `ERROR` diagnostics rather than the + * default `WARN` level. + */ + failOnError?: boolean; + /** + * When `true`, asynchronous imports like `import("some-module")` will not + * be considered connections that can create cycles. + */ + allowAsyncCycles?: boolean; + /** + * Cycles containing any module name that matches this regex will _not_ be + * counted as a cycle. + */ + exclude?: RegExp; + /** + * List of dependency connections that should not count for creating cycles. + * Connections are represented as `[from, to]`, where each entry is matched + * against the _identifier_ for that module in the connection. The + * identifier contains the full, unique path for the module, including all + * of the loaders that were applied to it and any request parameters. + * + * When an entry is a String, it is tested as a _substring_ of the + * identifier. For example, the entry "components/Button" would match the + * module "app/design/components/Button.tsx". When the entry is a RegExp, + * it is tested against the entire identifier. + */ + ignoredConnections?: Array<[string | RegExp, string | RegExp]>; + /** + * Called once for every detected cycle. Providing this handler overrides the + * default behavior of adding diagnostics to the compilation. + */ + onDetected?( + entrypoint: Module, + modules: string[], + compilation: Compilation + ): void; + /** + * Called once for every detected cycle that was ignored because of a rule, + * either from `exclude` or `ignoredConnections`. + */ + onIgnored?( + entrypoint: Module, + modules: string[], + compilation: Compilation + ): void; + /** + * Called before cycle detection begins. + */ + onStart?(compilation: Compilation): void; + /** + * Called after cycle detection finishes. + */ + onEnd?(compilation: Compilation): void; +}; + +export const CircularDependencyRspackPlugin = create( + BuiltinPluginName.CircularDependencyRspackPlugin, + ( + options: CircularDependencyRspackPluginOptions + ): RawCircularDependencyRspackPluginOptions => { + return { + allowAsyncCycles: options.allowAsyncCycles, + failOnError: options.failOnError, + exclude: options.exclude, + ignoredConnections: options.ignoredConnections, + onDetected: options.onDetected, + onIgnored: options.onIgnored, + onStart: options.onStart, + onEnd: options.onEnd + }; + }, + "compilation" +); diff --git a/packages/rspack/src/builtin-plugin/index.ts b/packages/rspack/src/builtin-plugin/index.ts index df4fdf32a72..4ba8664802f 100644 --- a/packages/rspack/src/builtin-plugin/index.ts +++ b/packages/rspack/src/builtin-plugin/index.ts @@ -6,6 +6,7 @@ export * from "./AssetModulesPlugin"; export * from "./AsyncWebAssemblyModulesPlugin"; export * from "./BannerPlugin"; export * from "./BundlerInfoRspackPlugin"; +export * from "./CircularDependencyRspackPlugin"; export * from "./ChunkPrefetchPreloadPlugin"; export * from "./CommonJsChunkFormatPlugin"; export * from "./CopyRspackPlugin"; diff --git a/packages/rspack/src/exports.ts b/packages/rspack/src/exports.ts index 4202b6ee53e..a298f1bbe03 100644 --- a/packages/rspack/src/exports.ts +++ b/packages/rspack/src/exports.ts @@ -23,6 +23,7 @@ export type { MultiCompilerOptions, MultiRspackOptions } from "./MultiCompiler"; export { MultiCompiler } from "./MultiCompiler"; import { RspackOptionsApply } from "./rspackOptionsApply"; + export { RspackOptionsApply, RspackOptionsApply as WebpackOptionsApply }; export type { Chunk } from "./Chunk"; @@ -44,6 +45,7 @@ export { RuntimeModule } from "./RuntimeModule"; // API extractor not working with some re-exports, see: https://github.com/microsoft/fluentui/issues/20694 import * as ModuleFilenameHelpers from "./lib/ModuleFilenameHelpers"; + export { ModuleFilenameHelpers }; // API extractor not working with some re-exports, see: https://github.com/microsoft/fluentui/issues/20694 @@ -54,6 +56,7 @@ export const WebpackError = Error; export type { Watching } from "./Watching"; import sources = require("webpack-sources"); + export { sources }; import { @@ -78,10 +81,12 @@ export const config: Config = { export type * from "./config"; import { ValidationError } from "./util/validate"; + export { ValidationError }; import { cachedCleverMerge as cleverMerge } from "./util/cleverMerge"; import { createHash } from "./util/createHash"; + export const util = { createHash, cleverMerge }; export { default as EntryOptionPlugin } from "./lib/EntryOptionPlugin"; @@ -118,9 +123,11 @@ export { LoaderTargetPlugin } from "./lib/LoaderTargetPlugin"; export { NormalModuleReplacementPlugin } from "./lib/NormalModuleReplacementPlugin"; import { FetchCompileAsyncWasmPlugin } from "./builtin-plugin"; + interface Web { FetchCompileAsyncWasmPlugin: typeof FetchCompileAsyncWasmPlugin; } + export const web: Web = { FetchCompileAsyncWasmPlugin }; @@ -128,11 +135,13 @@ export const web: Web = { import { NodeTargetPlugin } from "./builtin-plugin"; import NodeEnvironmentPlugin from "./node/NodeEnvironmentPlugin"; import NodeTemplatePlugin from "./node/NodeTemplatePlugin"; + interface Node { NodeTargetPlugin: typeof NodeTargetPlugin; NodeTemplatePlugin: typeof NodeTemplatePlugin; NodeEnvironmentPlugin: typeof NodeEnvironmentPlugin; } + export const node: Node = { NodeTargetPlugin, NodeTemplatePlugin, @@ -140,40 +149,50 @@ export const node: Node = { }; import { ElectronTargetPlugin } from "./builtin-plugin"; + interface Electron { ElectronTargetPlugin: typeof ElectronTargetPlugin; } + export const electron: Electron = { ElectronTargetPlugin }; import { EnableLibraryPlugin } from "./builtin-plugin"; + interface Library { EnableLibraryPlugin: typeof EnableLibraryPlugin; } + export const library: Library = { EnableLibraryPlugin }; import { EnableWasmLoadingPlugin } from "./builtin-plugin"; + interface Wasm { EnableWasmLoadingPlugin: typeof EnableWasmLoadingPlugin; } + export const wasm: Wasm = { EnableWasmLoadingPlugin }; import { EnableChunkLoadingPlugin, JavascriptModulesPlugin } from "./builtin-plugin"; + interface JavaScript { EnableChunkLoadingPlugin: typeof EnableChunkLoadingPlugin; JavascriptModulesPlugin: typeof JavascriptModulesPlugin; } + export const javascript: JavaScript = { EnableChunkLoadingPlugin, JavascriptModulesPlugin }; import { WebWorkerTemplatePlugin } from "./builtin-plugin"; + interface Webworker { WebWorkerTemplatePlugin: typeof WebWorkerTemplatePlugin; } + export const webworker: Webworker = { WebWorkerTemplatePlugin }; import { LimitChunkCountPlugin } from "./builtin-plugin"; @@ -186,6 +205,7 @@ interface Optimize { RuntimeChunkPlugin: typeof RuntimeChunkPlugin; SplitChunksPlugin: typeof SplitChunksPlugin; } + export const optimize: Optimize = { LimitChunkCountPlugin, RuntimeChunkPlugin, @@ -193,11 +213,14 @@ export const optimize: Optimize = { }; import { ModuleFederationPlugin } from "./container/ModuleFederationPlugin"; + export type { ModuleFederationPluginOptions } from "./container/ModuleFederationPlugin"; import { ModuleFederationPluginV1 } from "./container/ModuleFederationPluginV1"; + export type { ModuleFederationPluginV1Options } from "./container/ModuleFederationPluginV1"; import { ContainerPlugin } from "./container/ContainerPlugin"; import { ContainerReferencePlugin } from "./container/ContainerReferencePlugin"; + export type { ContainerPluginOptions, Exposes, @@ -224,6 +247,7 @@ export const container = { import { ConsumeSharedPlugin } from "./sharing/ConsumeSharedPlugin"; import { ProvideSharedPlugin } from "./sharing/ProvideSharedPlugin"; import { SharePlugin } from "./sharing/SharePlugin"; + export type { Consumes, ConsumesConfig, @@ -255,6 +279,7 @@ export const sharing = { export type { HtmlRspackPluginOptions } from "./builtin-plugin"; export type { SwcJsMinimizerRspackPluginOptions } from "./builtin-plugin"; export type { LightningCssMinimizerRspackPluginOptions } from "./builtin-plugin"; +export type { CircularDependencyRspackPluginOptions } from "./builtin-plugin"; export type { CopyRspackPluginOptions } from "./builtin-plugin"; export type { SourceMapDevToolPluginOptions } from "./builtin-plugin"; export type { EvalDevToolModulePluginOptions } from "./builtin-plugin"; @@ -265,6 +290,7 @@ export type { export { HtmlRspackPlugin } from "./builtin-plugin"; export { SwcJsMinimizerRspackPlugin } from "./builtin-plugin"; export { LightningCssMinimizerRspackPlugin } from "./builtin-plugin"; +export { CircularDependencyRspackPlugin } from "./builtin-plugin"; export { CopyRspackPlugin } from "./builtin-plugin"; export { SourceMapDevToolPlugin } from "./builtin-plugin"; export { EvalSourceMapDevToolPlugin } from "./builtin-plugin"; @@ -291,6 +317,7 @@ export type { ///// Experiments Stuff ///// import { cleanupGlobalTrace, registerGlobalTrace } from "@rspack/binding"; + interface Experiments { globalTrace: { register: typeof registerGlobalTrace; diff --git a/website/docs/en/plugins/rspack/circulary-dependency-rspack-plugin.mdx b/website/docs/en/plugins/rspack/circulary-dependency-rspack-plugin.mdx new file mode 100644 index 00000000000..e288ac07ba6 --- /dev/null +++ b/website/docs/en/plugins/rspack/circulary-dependency-rspack-plugin.mdx @@ -0,0 +1,252 @@ +import { ApiMeta } from '@components/ApiMeta.tsx'; + +# CircularDependencyRspackPlugin + + + +Detects circular import dependencies between modules that will exist at runtime. Import cycles are often _not_ indicative of a bug when used by functions called at runtime, but may cause bugs or errors when the imports are used during initialization. This plugin does not distinguish between the two, but can be used to help identify and resolve cycles after a bug has been encountered. + +```js +new rspack.CircularDependencyRspackPlugin(options); +``` + +## Comparison + +This plugin is an adaptation of the original [`circular-dependency-plugin`](https://github.com/aackerman/circular-dependency-plugin) for Webpack and diverges in both behavior and features.CircularDependencyRspackPlugin + +### Performance + +Because `CircularDependencyRspackPlugin` is implemented in Rust, it is able to integrate directly with the module graph and avoid expensive copying and serialization. On top of that, this plugin operates using a single traversal of the module graph for each entrypoint to identify all cycles rather than checking each module individually. Combined, that means `CircularDependencyRspackPlugin` is able to run fast enough to be part of a hot reload development cycle without any noticeable impact on reload times, even for extremely large projects with hundreds of thousands of modules and imports. + +### Features + +`CircularDependencyRspackPlugin` aims to be compatible with the features of `circular-dependency-plugin`, with modifications to give finer control over cycle detection and behavior. + +One notable difference between the two is the use of module _identifiers_ for cycle entries in Rspack rather than relative paths. Identifiers represent the entire unique name for a bundled module, including the set of loaders that processed it, the absolute module path, and any request parameters that were provided when importing. While matching on just the path of the module is still possible, identifiers allow for matching against loaders and rulesets as well. + +This plugin also provides a new option, `ignoredConnections` to allow for more granular control over whether a cycle is ignored. The `exclude` option from the original plugin is still implemented to match existing behavior, but causes any cycle containing the module to be ignored entirely. When only specific cycles are meant to be ignored, `ignoredConnections` allows for specifying both a `from` and a `to` pattern to match against, ignoring only cycles where an explicit dependency between two modules is present. + +## Examples + +- Detect all cycles present in a compilation, ignoring any that include external packages from `node_modules`, and emitting compilation errors for each cycle. + +```ts title="rspack.config.js" +const rspack = require('@rspack/core'); + +module.exports = { + entry: './src/index.js', + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + failOnError: true, + exclude: /node_modules/, + }), + ], +}; +``` + +- Ignore a specific connection between two modules, as well as any connection `to` any module matching a given pattern. + +```ts title="rspack.config.js" +const rspack = require('@rspack/core'); + +module.exports = { + entry: './src/index.js', + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + ignoredConnections: [ + ['some/module/a.js', 'some/module/b.js'], + ['', /modules\/.*Store.js/], + ], + }), + ], +}; +``` + +- Allow asynchronous cycles (such as `import('some/module')`) and manually handle all detected cycles. + +```ts title="rspack.config.js" +const rspack = require('@rspack/core'); + +module.exports = { + entry: './src/index.js', + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + allowAsyncCycles: true, + onDetected(entrypoint, modules, compilation) { + compilation.errors.push( + new Error(`Cycle in ${entrypoint}: ${modules.join(' -> ')}`), + ); + }, + }), + ], +}; +``` + +## Options + +### failOnError + +- **Type:** `boolean` +- **Default:** `false` + +When `true`, detected cycles will generate Error level diagnostics rather than Warnings. This will have no noticeable effect in watch mode, but will cause full builds to fail when the errors are emitted. + +```js title="rspack.config.js" +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + failOnError: true, + }), + ], +}; +``` + +### allowAsyncCycles + +- **Type:** `boolean` +- **Default:** `false` + +Allow asynchronous imports and connections to cause a detected cycle to be ignored. Asynchronous imports include `import()` function calls, weak imports for lazy compilation, hot module connections, and more. + +```js title="rspack.config.js" +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + allowAsyncCycles: true, + }), + ], +}; +``` + +### exclude + +- **Type:** `RegExp` +- **Default:** `undefined` + +Similar to `exclude` from the original [`circular-dependency-plugin`](https://github.com/aackerman/circular-dependency-plugin), detected cycles containing any module identifier that matches this pattern will be ignored. + +```js +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + // Ignore any cycles involving external packages from `node_modules` + exclude: /node_modules/, + }), + ], +}; +``` + +### ignoredConnections + +- **Type:** `Array<[string | RegExp, string | RegExp]>` +- **Default:** `[]` + +A list of explicit connections that should cause a detected cycle to be ignored. Each entry in the list represents a connection as `[from, to]`, matching any connection where `from` depends on `to`. + +```js +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + ignoredConnections: [ + // Ignore a specific connection between modules + ['some/module/a', 'some/module/b'], + // Ignore any connection depending on `b` + ['', 'some/module/b'], + // Ignore any connection between "Store-like" modules + [/.*Store\.js/, /.*Store\.js/], + ], + }), + ], +}; +``` + +Each pattern can be represented as either a plain string or a `RegExp`. Plain strings will be matched as substrings against the module identifier for that part of a connection, and RegExps will match anywhere in the entire identifier. For example: + +- The string `'some/module/'` will match any module in the `some/module` directory, like `some/module/a` and `some/module/b`. +- The RegExp `!file-loader!.*\.mdx` will match any `.mdx` module processed by `file-loader`. +- Empty strings effectively match any module, since an empty string is always a substring of any other string. + +### onDetected + +- **Type:** `(entrypoint: string, modules: string[], compilation: Compilation) => void` +- **Default:** `undefined` + +Handler function called for every detected cycle. Providing this handler overrides the default behavior of adding diagnostics to the compilation, meaning the value of `failOnError` will be effectively unused. + +```js title="rspack.config.js" +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + onDetected(entrypoint, modules, compilation) { + console.log(`Found a cycle in ${entrypoint}: ${modules.join(' -> ')}`); + }, + }), + ], +}; +``` + +This handler can be used to process detected cycles further before emitting warnings or errors to the compilation, or to handle them in any other way not directly implemented by the plugin. + +`entrypoint` is the name of the entry where this cycle was detected. Because of how entrypoints are traversed for cycle detection, it's possible that the same cycle will be detected multiple times, once for each entrypoint. + +`modules` is the list of identifiers of module contained in the cycle, where both the first and the last entry of the list will always be the same module, and the only module that is present more than once in the list. + +`compilation` is the full Compilation object, allowing the handler to emit errors or inspect any other part of the bundle as needed. + +### onIgnored + +- **Type:** `(entrypoint: string, modules: string[], compilation: Compilation) => void` +- **Default:** `undefined` + +Handler function called for every detected cycle that was intentionally ignored, whether by the `exclude` pattern, any match of an `ignoredConnection`, or any other possible reason. + +```js title="rspack.config.js" +const ignoredCycles = []; +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + onIgnored(entrypoint, modules, compilation) { + ignoredCycles.push({ entrypoint, modules }); + }, + }), + ], +}; +``` + +### onStart + +- **Type:** `(compilation: Compilation) => void` +- **Default:** `undefined` + +Hook function called immediately before cycle detection begins, useful for setting up temporary state to use in the `onDetected` handler or logging progress. + +```js title="rspack.config.js" +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + onStart(compilation) { + console.log('Starting circular dependency detection'); + }, + }), + ], +}; +``` + +### onEnd + +- **Type:** `(compilation: Compilation) => void` +- **Default:** `undefined` + +Hook function called immediately after cycle detection finishes, useful for cleaning up temporary state or logging progress. + +```js title="rspack.config.js" +module.exports = { + plugins: [ + new rspack.CircularDependencyRspackPlugin({ + onEnd(compilation) { + console.log('Finished detecting circular dependencies'); + }, + }), + ], +}; +```