diff --git a/crates/oxc_angular_compiler/src/class_metadata/compiler.rs b/crates/oxc_angular_compiler/src/class_metadata/compiler.rs index b2f2cac53..a75c618d4 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/compiler.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/compiler.rs @@ -119,73 +119,7 @@ pub fn compile_component_metadata_async_resolver<'a>( let mut dynamic_imports = Vec::new_in(&allocator); for dep in dependencies { - // Create: (m) => m.CmpA (or m.default for default imports) - let mut inner_params = Vec::new_in(&allocator); - inner_params.push(FnParam { name: Ident::from("m") }); - - let prop_name = - if dep.is_default_import { Ident::from("default") } else { dep.export_name.clone() }; - - let inner_body = OutputExpression::ReadProp(Box::new_in( - ReadPropExpr { - receiver: Box::new_in( - OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: Ident::from("m"), source_span: None }, - &allocator, - )), - &allocator, - ), - name: prop_name, - optional: false, - source_span: None, - }, - &allocator, - )); - - let inner_fn = OutputExpression::ArrowFunction(Box::new_in( - ArrowFunctionExpr { - params: inner_params, - body: ArrowFunctionBody::Expression(Box::new_in(inner_body, &allocator)), - source_span: None, - }, - &allocator, - )); - - // Create: import('./cmp-a').then((m) => m.CmpA) - let dynamic_import = OutputExpression::DynamicImport(Box::new_in( - DynamicImportExpr { - url: DynamicImportUrl::String(dep.import_path.clone()), - url_comment: None, - source_span: None, - }, - &allocator, - )); - - let then_prop = OutputExpression::ReadProp(Box::new_in( - ReadPropExpr { - receiver: Box::new_in(dynamic_import, &allocator), - name: Ident::from("then"), - optional: false, - source_span: None, - }, - &allocator, - )); - - let mut then_args = Vec::new_in(&allocator); - then_args.push(inner_fn); - - let then_call = OutputExpression::InvokeFunction(Box::new_in( - InvokeFunctionExpr { - fn_expr: Box::new_in(then_prop, &allocator), - args: then_args, - pure: false, - optional: false, - source_span: None, - }, - &allocator, - )); - - dynamic_imports.push(then_call); + dynamic_imports.push(compile_deferred_dependency_import(allocator, dep)); } // Create: () => [...] @@ -205,6 +139,80 @@ pub fn compile_component_metadata_async_resolver<'a>( )) } +/// One entry of a dependency resolver function: +/// `import('./cmp-a').then((m) => m.CmpA)`, or `m.default` for a default +/// import (the `isDeferrable` branch of Angular's +/// `compileDeferResolverFunction`). +pub fn compile_deferred_dependency_import<'a>( + allocator: &'a Allocator, + dep: &R3DeferPerComponentDependency<'a>, +) -> OutputExpression<'a> { + // Create: (m) => m.CmpA (or m.default for default imports) + let mut inner_params = Vec::new_in(&allocator); + inner_params.push(FnParam { name: Ident::from("m") }); + + let prop_name = if dep.is_default_import { Ident::from("default") } else { dep.export_name }; + + let inner_body = OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in( + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from("m"), source_span: None }, + &allocator, + )), + &allocator, + ), + name: prop_name, + optional: false, + source_span: None, + }, + &allocator, + )); + + let inner_fn = OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { + params: inner_params, + body: ArrowFunctionBody::Expression(Box::new_in(inner_body, &allocator)), + source_span: None, + }, + &allocator, + )); + + // Create: import('./cmp-a').then((m) => m.CmpA) + let dynamic_import = OutputExpression::DynamicImport(Box::new_in( + DynamicImportExpr { + url: DynamicImportUrl::String(dep.import_path), + url_comment: None, + source_span: None, + }, + &allocator, + )); + + let then_prop = OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in(dynamic_import, &allocator), + name: Ident::from("then"), + optional: false, + source_span: None, + }, + &allocator, + )); + + let mut then_args = Vec::new_in(&allocator); + then_args.push(inner_fn); + + OutputExpression::InvokeFunction(Box::new_in( + InvokeFunctionExpr { + fn_expr: Box::new_in(then_prop, &allocator), + args: then_args, + pure: false, + optional: false, + source_span: None, + }, + &allocator, + )) +} + // ============================================================================ // Internal helpers // ============================================================================ diff --git a/crates/oxc_angular_compiler/src/class_metadata/mod.rs b/crates/oxc_angular_compiler/src/class_metadata/mod.rs index 0073b8426..98fe7cf50 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/mod.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/mod.rs @@ -21,6 +21,7 @@ pub use builders::{ pub(crate) use builders::{entity_path, param_type_reference, type_param_names_of}; pub use compiler::{ compile_class_metadata, compile_component_class_metadata, - compile_component_metadata_async_resolver, compile_opaque_async_class_metadata, + compile_component_metadata_async_resolver, compile_deferred_dependency_import, + compile_opaque_async_class_metadata, }; pub use metadata::{R3ClassMetadata, R3DeferPerComponentDependency}; diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs index e4ae4d95f..d0d9f81a1 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -210,7 +210,44 @@ fn standalone_class_selector<'a>( } _ => None, })?; + decorated_class_selector(class, consts, implicit_standalone) +} + +/// The selector of the top-level class `local` declares in `program`, +/// exported or not, when it is a standalone `@Component` or `@Directive` whose +/// `selector` and `standalone` are literals: an `imports` entry that names a +/// class of the file being compiled. `consts` are the program's. +pub fn local_standalone_class_selector<'a>( + program: &'a Program<'a>, + consts: &StringConsts<'a>, + local: &str, + implicit_standalone: bool, +) -> Option { + let is_local = |class: &Class| class.id.as_ref().is_some_and(|id| id.name == local); + let class = program.body.iter().find_map(|stmt| match stmt { + Statement::ClassDeclaration(class) if is_local(class) => Some(&**class), + Statement::ExportDeclaration(decl) => match &decl.declaration { + Declaration::ClassDeclaration(class) if is_local(class) => Some(&**class), + _ => None, + }, + Statement::ExportDefaultDeclaration(decl) => match &decl.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) if is_local(class) => { + Some(&**class) + } + _ => None, + }, + _ => None, + })?; + decorated_class_selector(class, consts, implicit_standalone) +} +/// The selector of `class` when it is a standalone `@Component` or +/// `@Directive` whose `selector` and `standalone` are literals. +fn decorated_class_selector<'a>( + class: &Class<'a>, + consts: &StringConsts<'a>, + implicit_standalone: bool, +) -> Option { let decorator = ["Component", "Directive"].iter().find_map(|name| { crate::directive::find_angular_class_decorator(&class.decorators, name, consts) })?; diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index b78871859..3801879ad 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -34,7 +34,9 @@ //! OXC does the same unless cross-file analysis can read the imported //! class's selector: then [`implicitly_deferred_imports`] picks the //! standalone components and directives the template matches only inside -//! `@defer` blocks, and they join `deferredImports` (#313). +//! `@defer` blocks, and they join `deferredImports` (#313). Each `@defer` +//! block then gets its own dependency function, as in ngc's full compilation +//! ([`defer_block_dependencies`], [`compile_defer_block_resolver`]). use oxc_allocator::Allocator; @@ -45,9 +47,12 @@ use crate::ast::r3::{ }; use crate::class_metadata::{ R3DeferPerComponentDependency, compile_component_metadata_async_resolver, + compile_deferred_dependency_import, }; use crate::i18n::serializer::is_i18n_attribute; -use crate::output::ast::OutputExpression; +use crate::output::ast::{ + ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, OutputExpression, ReadVarExpr, +}; use crate::parser::html::split_ns_name; use crate::pipeline::selector::CssSelector; use oxc_str::Ident; @@ -211,60 +216,48 @@ pub(super) fn implicitly_deferred_imports<'a>( nodes: &[R3Node<'a>], candidates: &[(Ident<'a>, std::vec::Vec)], ) -> std::vec::Vec> { - struct Matcher<'c, 'a> { - candidates: &'c [(Ident<'a>, std::vec::Vec)], - in_defer: bool, - deferred: std::vec::Vec, - eager: std::vec::Vec, - selectorless: bool, + let mut matcher = CandidateMatcher::new(candidates); + visit_all(&mut matcher, nodes); + if matcher.selectorless { + return std::vec::Vec::new(); } + candidates + .iter() + .enumerate() + .filter(|(index, _)| matcher.deferred[*index] && !matcher.eager[*index]) + .map(|(_, (name, _))| *name) + .collect() +} - impl Matcher<'_, '_> { - fn record(&mut self, element: &CssSelector) { - for (index, (_, selectors)) in self.candidates.iter().enumerate() { - if selectors.iter().any(|selector| selector.matches(element)) { - if self.in_defer { - self.deferred[index] = true; - } else { - self.eager[index] = true; - } - } - } - } +/// The `candidates` each `@defer` block uses, with the block's span, in +/// [`defer_block_spans`] order. ngc binds the main content of each block on +/// its own (`resolveDeferBlocks` in its component `handler.ts`) and keeps +/// what that content uses eagerly: a candidate matched only inside a nested +/// `@defer` belongs to the nested block, while the nested block's +/// `@placeholder`, `@loading` and `@error` belong to the outer one. Each list +/// keeps the order of `candidates`. `None` for a selectorless template. +pub(super) fn defer_block_dependencies<'a>( + nodes: &[R3Node<'a>], + candidates: &[(Ident<'a>, std::vec::Vec)], +) -> Option>)>> { + struct BlockCollector<'c, 'a> { + candidates: &'c [(Ident<'a>, std::vec::Vec)], + blocks: std::vec::Vec<(oxc_span::Span, std::vec::Vec>)>, } - impl<'a> R3Visitor<'a> for Matcher<'_, 'a> { - fn visit_element(&mut self, element: &R3Element<'a>) { - if !element.directives.is_empty() { - self.selectorless = true; - } - self.record(&element_selector( - &element.name, - &element.attributes, - &element.inputs, - &element.outputs, - )); - self.visit_element_children(element); - } - - fn visit_template(&mut self, template: &R3Template<'a>) { - if !template.directives.is_empty() { - self.selectorless = true; - } - self.record(&template_selector(template)); - self.visit_template_children(template); - } - + impl<'a> R3Visitor<'a> for BlockCollector<'_, 'a> { fn visit_deferred_block(&mut self, block: &R3DeferredBlock<'a>) { - let outer = self.in_defer; - self.in_defer = true; - for child in &block.children { - child.visit(self); + let mut matcher = CandidateMatcher::new(self.candidates); + visit_all(&mut matcher, &block.children); + let mut dependencies = std::vec::Vec::new(); + for (index, (name, _)) in self.candidates.iter().enumerate() { + if matcher.eager[index] && !dependencies.contains(name) { + dependencies.push(*name); + } } - // `@placeholder`, `@loading` and `@error` render before the chunk - // loads, so they are eager unless an outer `@defer` holds them - // (`visitDeferredBlock` in t2_binder's `DirectiveBinder`). - self.in_defer = outer; + self.blocks.push((block.source_span, dependencies)); + + visit_all(self, &block.children); if let Some(placeholder) = &block.placeholder { self.visit_deferred_block_placeholder(placeholder); } @@ -275,33 +268,148 @@ pub(super) fn implicitly_deferred_imports<'a>( self.visit_deferred_block_error(error); } } + } - fn visit_component(&mut self, _component: &R3Component<'a>) { + let mut matcher = CandidateMatcher::new(candidates); + visit_all(&mut matcher, nodes); + if matcher.selectorless { + return None; + } + let mut collector = BlockCollector { candidates, blocks: std::vec::Vec::new() }; + visit_all(&mut collector, nodes); + Some(collector.blocks) +} + +/// A `@defer` block's dependency function, for `DeferBlockDepsEmitMode` +/// `PerBlock` (`compileDeferResolverFunction` in Angular's +/// `render3/view/compiler.ts`): a dynamic `import()` for each of the +/// `dependencies` that `deferred` lists, and a plain reference to the others. +/// `None` when the block has no dependencies (`compileDeferBlocks` in ngc's +/// component `handler.ts`). +pub(super) fn compile_defer_block_resolver<'a>( + allocator: &'a Allocator, + dependencies: &[Ident<'a>], + deferred: &[Ident<'a>], + import_map: &ImportMap<'a>, +) -> Option> { + if dependencies.is_empty() { + return None; + } + let mut entries = oxc_allocator::Vec::new_in(&allocator); + for name in dependencies { + let deferrable = if deferred.contains(name) { + build_defer_per_component_deps(allocator, std::slice::from_ref(name), import_map) + .first() + .map(|dep| compile_deferred_dependency_import(allocator, dep)) + } else { + None + }; + entries.push(deferrable.unwrap_or_else(|| { + OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: *name, source_span: None }, + &allocator, + )) + })); + } + let array = OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + LiteralArrayExpr { entries, source_span: None }, + &allocator, + )); + Some(OutputExpression::ArrowFunction(oxc_allocator::Box::new_in( + ArrowFunctionExpr { + params: oxc_allocator::Vec::new_in(&allocator), + body: ArrowFunctionBody::Expression(oxc_allocator::Box::new_in(array, &allocator)), + source_span: None, + }, + &allocator, + ))) +} + +/// Which `candidates` a template matches inside the main content of `@defer` +/// blocks and which it matches anywhere else, as t2_binder's +/// `DirectiveBinder` tells eagerly used directives apart. +struct CandidateMatcher<'c, 'a> { + candidates: &'c [(Ident<'a>, std::vec::Vec)], + in_defer: bool, + deferred: std::vec::Vec, + eager: std::vec::Vec, + selectorless: bool, +} + +impl<'c, 'a> CandidateMatcher<'c, 'a> { + fn new(candidates: &'c [(Ident<'a>, std::vec::Vec)]) -> Self { + Self { + candidates, + in_defer: false, + deferred: vec![false; candidates.len()], + eager: vec![false; candidates.len()], + selectorless: false, + } + } + + fn record(&mut self, element: &CssSelector) { + for (index, (_, selectors)) in self.candidates.iter().enumerate() { + if selectors.iter().any(|selector| selector.matches(element)) { + if self.in_defer { + self.deferred[index] = true; + } else { + self.eager[index] = true; + } + } + } + } +} + +impl<'a> R3Visitor<'a> for CandidateMatcher<'_, 'a> { + fn visit_element(&mut self, element: &R3Element<'a>) { + if !element.directives.is_empty() { self.selectorless = true; } + self.record(&element_selector( + &element.name, + &element.attributes, + &element.inputs, + &element.outputs, + )); + self.visit_element_children(element); + } - fn visit_directive(&mut self, _directive: &R3Directive<'a>) { + fn visit_template(&mut self, template: &R3Template<'a>) { + if !template.directives.is_empty() { self.selectorless = true; } + self.record(&template_selector(template)); + self.visit_template_children(template); } - let mut matcher = Matcher { - candidates, - in_defer: false, - deferred: vec![false; candidates.len()], - eager: vec![false; candidates.len()], - selectorless: false, - }; - visit_all(&mut matcher, nodes); - if matcher.selectorless { - return std::vec::Vec::new(); + fn visit_deferred_block(&mut self, block: &R3DeferredBlock<'a>) { + let outer = self.in_defer; + self.in_defer = true; + for child in &block.children { + child.visit(self); + } + // `@placeholder`, `@loading` and `@error` render before the chunk + // loads, so they are eager unless an outer `@defer` holds them + // (`visitDeferredBlock` in t2_binder's `DirectiveBinder`). + self.in_defer = outer; + if let Some(placeholder) = &block.placeholder { + self.visit_deferred_block_placeholder(placeholder); + } + if let Some(loading) = &block.loading { + self.visit_deferred_block_loading(loading); + } + if let Some(error) = &block.error { + self.visit_deferred_block_error(error); + } + } + + fn visit_component(&mut self, _component: &R3Component<'a>) { + self.selectorless = true; + } + + fn visit_directive(&mut self, _directive: &R3Directive<'a>) { + self.selectorless = true; } - candidates - .iter() - .enumerate() - .filter(|(index, _)| matcher.deferred[*index] && !matcher.eager[*index]) - .map(|(_, (name, _))| *name) - .collect() } /// Whether any element or template anywhere in the template (`@defer` blocks diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index ea6984a72..75d21950d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3562,43 +3562,52 @@ pub fn transform_angular_file( content_queries.iter().map(|q| q.property_name.to_string()).collect(); // The selector of the standalone component or directive an - // `imports` entry refers to, when `@defer` may lazy-load it - // (#313): its import is referenced nowhere else in this file - // or in the compiled output, so the static import can go, and - // cross-file analysis can read the selector. Watched files - // are only reported with `resolve_imported_values`, so it is - // required here too. + // `imports` entry refers to, when cross-file analysis can + // read it or the class is declared in this file, and whether + // `@defer` may lazy-load it (#313): its import is referenced + // nowhere else in this file or in the compiled output, so the + // static import can go. Watched files are only reported with + // `resolve_imported_values`, so it is required here too. #[cfg(feature = "cross_file_elision")] - let deferrable_selector = cross_file_analyzer + let import_selector = cross_file_analyzer .as_ref() .filter(|_| options.resolve_imported_values) .map(|analyzer| { let import_map = &import_map; let import_elision = &import_elision_mut; - move |local: &str| -> Option { - if import_elision.value_reference_count(local)? != 1 - || import_elision.is_kept_by_output(local) - { - return None; - } - let info = import_map.get(&Ident::from(local))?; + let program = &parser_ret.program; + let string_consts = &string_consts; + move |local: &str| -> Option<(String, bool)> { + let Some(info) = import_map.get(&Ident::from(local)) else { + let selector = + super::cross_file_elision::local_standalone_class_selector( + program, + string_consts, + local, + implicit_standalone, + )?; + return Some((selector, false)); + }; if !info.is_named_import || info.is_type_only { return None; } let export_name = info.imported_name.as_deref().unwrap_or(local); - analyzer.standalone_directive_selector( + let selector = analyzer.standalone_directive_selector( info.source_module.as_str(), export_name, std::path::Path::new(path), implicit_standalone, - ) + )?; + let deferrable = import_elision.value_reference_count(local) + == Some(1) + && !import_elision.is_kept_by_output(local); + Some((selector, deferrable)) } }); #[cfg(feature = "cross_file_elision")] - let deferrable_selector = - deferrable_selector.as_ref().map(|f| f as DeferrableSelector<'_>); + let import_selector = import_selector.as_ref().map(|f| f as ImportSelector<'_>); #[cfg(not(feature = "cross_file_elision"))] - let deferrable_selector = None; + let import_selector = None; // 4. Compile the template and generate ɵcmp/ɵfac // Pass the shared pool index to ensure unique constant names @@ -3616,7 +3625,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, &import_map, resolved_resources, - deferrable_selector, + import_selector, Some(&import_elision_mut), false, ) { @@ -5442,8 +5451,8 @@ fn html_parse_error_diagnostic( } /// The selector of the standalone component or directive an `imports` -/// entry refers to, by local name, when `@defer` may lazy-load it. -type DeferrableSelector<'f> = &'f dyn Fn(&str) -> Option; +/// entry refers to, by local name, and whether `@defer` may lazy-load it. +type ImportSelector<'f> = &'f dyn Fn(&str) -> Option<(String, bool)>; fn compile_component_full<'a>( allocator: &'a Allocator, @@ -5458,7 +5467,7 @@ fn compile_component_full<'a>( namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, resolved_resources: Option<&ResolvedResources>, - deferrable_selector: Option>, + import_selector: Option>, import_elision: Option<&ImportElisionAnalyzer<'_>>, hmr_update: bool, ) -> Result> { @@ -5632,7 +5641,10 @@ fn compile_component_full<'a>( // ngc's full compilation (#313). Needs cross-file analysis for the // selectors; skipped under HMR, like ngc. let mut implicitly_deferred_imports = Vec::new(); - if let Some(deferrable_selector) = deferrable_selector + // What each `@defer` block uses, keyed by the block's span, when its own + // dependency function can be built (see below). + let mut defer_block_dependencies = None; + if let Some(import_selector) = import_selector && let Some(import_elision) = import_elision && !options.hmr && let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports @@ -5644,24 +5656,30 @@ fn compile_component_full<'a>( .all(|(entry, class)| class.is_some() || matches!(entry, OutputExpression::ReadVar(_))) && super::defer_resolver::template_has_defer_block(&r3_result.nodes) { + let mut deferrable = Vec::new(); let candidates: Vec<_> = metadata .import_entry_classes .iter() .flatten() .copied() .filter_map(|name| { - let selector = deferrable_selector(name.as_str())?; + let (selector, can_defer) = import_selector(name.as_str())?; + if can_defer { + deferrable.push(name); + } Some((name, crate::pipeline::selector::CssSelector::parse(&selector))) }) .collect(); - let deferred = + let block_only = super::defer_resolver::implicitly_deferred_imports(&r3_result.nodes, &candidates); + let mut deferred = block_only.clone(); + deferred.retain(|name| deferrable.contains(name)); // ngc's `DeferredSymbolTracker.canDefer`: an import declaration goes // only when every non-`type` specifier in it is deferred, or the // static import would stay and nothing would be split off. An // explicit `deferredImports` entry doesn't count: OXC keeps its // static import. - let deferred: std::vec::Vec<_> = deferred + let mut deferred: std::vec::Vec<_> = deferred .iter() .copied() .filter(|name| { @@ -5671,15 +5689,53 @@ fn compile_component_full<'a>( .all(|specifier| deferred.contains(specifier)) }) .collect(); - if !deferred.is_empty() { - let is_deferred = |name: &str| deferred.iter().any(|d| d.as_str() == name); - metadata.imports.retain(|name| !is_deferred(name.as_str())); + // ngc's full compilation gives each `@defer` block its own dependency + // function (`DeferBlockDepsEmitMode.PerBlock`) listing what the + // block's main content uses: a dynamic `import()` for a lazy + // dependency and a plain reference for an eager one + // (`resolveDeferBlocks` and `compileDeferBlocks` in its component + // `handler.ts`). An `imports` entry whose selector is unknown is left + // out; it stays eager, and `dependencies` already provides it to the + // block. An explicit `deferredImports` keeps the shared function: its + // selectors aren't read. + if metadata.deferred_imports.is_empty() && !options.jit { + defer_block_dependencies = + super::defer_resolver::defer_block_dependencies(&r3_result.nodes, &candidates); + } + if let Some(blocks) = &defer_block_dependencies { + // `resolveAllDeferredDependencies` lists the lazy dependencies of + // `setClassMetadataAsync` block by block. + deferred.sort_by_key(|name| { + blocks.iter().flat_map(|(_, dependencies)| dependencies).position(|d| d == name) + }); + } + // `dependencies` lists only what the template uses eagerly + // (`handleDependencyCycles` in ngc's component `handler.ts`). With a + // dependency function per block, an entry used only inside `@defer` + // blocks leaves it even when it can't be lazy-loaded: the blocks' + // functions provide it. Otherwise only the deferred entries leave. + let removed = if defer_block_dependencies.is_some() { &block_only } else { &deferred }; + if !removed.is_empty() { + let is_removed = |name: &str| removed.iter().any(|d| d.as_str() == name); + metadata.imports.retain(|name| !is_removed(name.as_str())); if let Some(OutputExpression::LiteralArray(raw_imports)) = &mut metadata.raw_imports { let mut classes = metadata.import_entry_classes.iter(); raw_imports.entries.retain(|_| { - !classes.next().copied().flatten().is_some_and(|name| is_deferred(&name)) + !classes.next().copied().flatten().is_some_and(|name| is_removed(&name)) }); + // Nothing left to resolve at runtime: like a standalone + // component without `imports`, it gets no `dependencies`. + if raw_imports.entries.is_empty() + && defer_block_dependencies.is_some() + && metadata.standalone + { + metadata.raw_imports = None; + metadata.declaration_list_emit_mode = + super::metadata::DeclarationListEmitMode::Direct; + } } + } + if !deferred.is_empty() { implicitly_deferred_imports = deferred.iter().map(ToString::to_string).collect(); metadata.deferred_imports.extend(deferred); } @@ -5702,8 +5758,9 @@ fn compile_component_full<'a>( // Note: Angular's full compilation mode also derives deferrable symbols // from `imports: [...]` by matching template selectors against imported // directive classes (`resolveAllDeferredDependencies`). OXC does that - // above when cross-file analysis is available (#313) and otherwise - // follows Angular's local path, which requires explicit `deferredImports`. + // above when cross-file analysis is available (#313), with one dependency + // function per block, and otherwise follows Angular's local path, which + // requires explicit `deferredImports`. // Computed once and reused below: the resolver decision and the eventual // `setClassMetadataAsync` gate both depend on whether the template uses // `@defer`. @@ -5713,20 +5770,23 @@ fn compile_component_full<'a>( // would resolve against the update module's URL, not the component file. // The update keeps a null dependency function instead (PerComponent mode // below), as `compile_template_for_hmr` always has. - let defer_resolver_fn = if metadata.deferred_imports.is_empty() || hmr_update { - None - } else if has_defer_block { - super::defer_resolver::build_defer_resolver_expression(allocator, metadata, import_map) - } else { - // `deferredImports` declared but no `@defer` block — the resolver - // would be unused dead code. Leave it off. - None - }; + let defer_resolver_fn = + if metadata.deferred_imports.is_empty() || hmr_update || defer_block_dependencies.is_some() + { + None + } else if has_defer_block { + super::defer_resolver::build_defer_resolver_expression(allocator, metadata, import_map) + } else { + // `deferredImports` declared but no `@defer` block — the resolver + // would be unused dead code. Leave it off. + None + }; // When we have a resolver, switch to PerComponent emit mode so the // ingest stage threads `all_deferrable_deps_fn` into each `DeferOp`. // Without a resolver, the existing PerBlock-vs-JIT behavior stays in - // place (and `all_deferrable_deps_fn = None`). + // place (and `all_deferrable_deps_fn = None`). Per-block dependencies + // never come with a resolver, so they get PerBlock. let defer_block_deps_emit_mode = if defer_resolver_fn.is_some() || (hmr_update && !metadata.deferred_imports.is_empty() && has_defer_block) { @@ -5758,7 +5818,20 @@ fn compile_component_full<'a>( // PerComponent mode: pass the shared resolver expression so the // ingest stage attaches it to every defer block's create op. all_deferrable_deps_fn: defer_resolver_fn, - defer_blocks: FxHashMap::default(), + // PerBlock mode: each block's own dependency function, if any. + defer_blocks: defer_block_dependencies + .unwrap_or_default() + .into_iter() + .map(|(span, dependencies)| { + let deps_fn = super::defer_resolver::compile_defer_block_resolver( + allocator, + &dependencies, + &metadata.deferred_imports, + import_map, + ); + (span, deps_fn) + }) + .collect(), // Use the shared pool starting index to avoid duplicate constant names // when compiling multiple components in the same file pool_starting_index, diff --git a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs index 640a40ee4..33dafca1a 100644 --- a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs +++ b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs @@ -329,8 +329,14 @@ impl<'a> ConstantPool<'a> { } } - // No equivalent function found, create a new entry - let fn_name = self.unique_name(name, use_unique_name); + // No equivalent function found, create a new entry. Without + // `use_unique_name` the name is taken as is and not claimed, so it + // doesn't move the pool's name indices. + let fn_name = if use_unique_name { + self.unique_name(name, true) + } else { + Ident::from(self.allocator.alloc_str(name)) + }; // Add the function to statements (not values) to match Angular TypeScript // Arrow functions become DeclareVarStmt, regular functions become DeclareFunctionStmt @@ -629,6 +635,7 @@ fn output_stmt_is_equivalent(a: &OutputStatement<'_>, b: &OutputStatement<'_>) - #[cfg(test)] mod tests { use super::*; + use crate::output::ast::{ArrowFunctionBody, ArrowFunctionExpr, LiteralExpr, LiteralValue}; #[test] fn test_string_deduplication() { @@ -643,4 +650,37 @@ mod tests { assert_ne!(idx1, idx3); assert_eq!(pool.len(), 2); } + + #[test] + fn test_shared_function_without_unique_name_takes_no_index() { + let allocator = &Allocator::default(); + let mut pool = ConstantPool::with_starting_index(allocator, 2); + let arrow = |value: f64| { + OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { + params: Vec::new_in(&allocator), + body: ArrowFunctionBody::Expression(Box::new_in( + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Number(value), source_span: None }, + &allocator, + )), + &allocator, + )), + source_span: None, + }, + &allocator, + )) + }; + + let first = pool.get_shared_function_reference(arrow(1.0), "Cmp_Defer_1_DepsFn", false); + let second = pool.get_shared_function_reference(arrow(2.0), "Cmp_Defer_3_DepsFn", false); + + let name = |expr: &OutputExpression<'_>| match expr { + OutputExpression::ReadVar(read) => read.name.to_string(), + _ => panic!("expected a variable reference"), + }; + assert_eq!(name(&first), "Cmp_Defer_1_DepsFn"); + assert_eq!(name(&second), "Cmp_Defer_3_DepsFn"); + assert_eq!(pool.next_name_index(), 2); + } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_defer_deps_fns.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_defer_deps_fns.rs index f57ee80d5..ef6dd16a2 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_defer_deps_fns.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_defer_deps_fns.rs @@ -57,12 +57,12 @@ pub fn resolve_defer_deps_fns(job: &mut ComponentCompilationJob<'_>) { for (view_xref, slot_id) in defer_locations { // Get the full path name from the view's fn_name // In Angular TS: const fullPathName = unit.fnName?.replace('_Template', ''); + // `String.prototype.replace` with a string pattern replaces only the + // first match. let full_path_name = job .view(view_xref) .and_then(|v| v.fn_name.as_ref()) - .map(|n| n.as_str().trim_end_matches("_Template")) - .unwrap_or(job.component_name.as_str()) - .to_string(); // Convert to owned String to avoid borrow issues + .map_or_else(|| job.component_name.to_string(), |n| n.replacen("_Template", "", 1)); // Find the defer op and take ownership of own_resolver_fn let view = diff --git a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs index 6f141ccd2..b364c73d2 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -92,11 +92,13 @@ export class Parent {} assert_lazy(code, "./lazy", "LazyCmp"); assert!( - compact.contains("ɵɵdefer(1,0,()=>[import(\"./lazy\")"), - "The resolver must be wired into `ɵɵdefer`. Output:\n{code}" + compact.contains("constParent_Defer_1_DepsFn=()=>[import(\"./lazy\")") + && compact.contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), + "The block's resolver must be wired into `ɵɵdefer`. Output:\n{code}" ); + // Like ngc: nothing is used eagerly, so there is no `dependencies`. assert!( - compact.contains("ɵɵgetComponentDepsFactory(Parent,[])"), + !compact.contains("dependencies:"), "The lazy component must leave the eager dependencies. Output:\n{code}" ); assert!( @@ -252,8 +254,8 @@ export class Parent {{}} ); let code = &result.code; assert_lazy(code, "./lazy", "LazyCmp"); - assert!(compact(code).contains("ɵɵdefer(1,0,()=>[import(\"./lazy\")"), "{code}"); - assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[])"), "{code}"); + assert!(compact(code).contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), "{code}"); + assert!(!compact(code).contains("dependencies:"), "{code}"); assert!(!code.contains("import { LazyCmp }"), "{code}"); } } @@ -822,10 +824,10 @@ export class Parent {} let code = &result.code; assert_eager(code, "./cmps"); assert!(code.contains("import { LazyCmp, EagerCmp }"), "{code}"); - assert!( - compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[LazyCmp,EagerCmp])"), - "{code}" - ); + // Like ngc: `LazyCmp` stays eager but is used only by the block, whose + // function provides it. + assert!(compact(code).contains("constParent_Defer_2_DepsFn=()=>[LazyCmp];"), "{code}"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[EagerCmp])"), "{code}"); } #[test] @@ -899,7 +901,7 @@ export class Parent {} let code = &result.code; assert_lazy(code, "./cmps", "LazyCmp"); assert_lazy(code, "./cmps", "EagerCmp"); - assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[])"), "{code}"); + assert!(!compact(code).contains("dependencies:"), "{code}"); assert!(!code.contains("from \"./cmps\""), "{code}"); } @@ -952,7 +954,8 @@ export class Parent { ); let code = &result.code; assert_eager(code, "./cmps"); - assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[LazyCmp])"), "{code}"); + assert!(compact(code).contains("constParent_Defer_1_DepsFn=()=>[LazyCmp];"), "{code}"); + assert!(!compact(code).contains("dependencies:"), "{code}"); } #[test] @@ -1105,3 +1108,394 @@ export class Parent {} ); assert_eager(&result.code, "./lazy"); } + +// --- Per-block dependency functions: ngc's full compilation gives each +// `@defer` block its own `_Defer__DepsFn` (`resolveDeferBlocks`, +// `compileDeferBlocks` and `compileDeferResolverFunction`) --- + +/// `app/other.ts`, a standalone component `OtherCmp` with `app-other`. +fn other_cmp(dir: &TempDir) { + create_test_file( + dir.path(), + "app/other.ts", + r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-other', template: 'other' }) +export class OtherCmp {} +", + ); +} + +#[test] +fn each_defer_block_loads_only_its_own_dependencies() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, OtherCmp], + template: '@defer { } @defer (on viewport) { } @placeholder {

x

}', +}) +export class Parent {} +", + ); + let code = &result.code; + let compact = compact(code); + assert!( + compact.contains( + "constParent_Defer_1_DepsFn=()=>[import(\"./lazy\").then((m)=>m.LazyCmp)];\ + constParent_Defer_5_DepsFn=()=>[import(\"./other\").then((m)=>m.OtherCmp)];" + ), + "Each block must get its own resolver. Output:\n{code}" + ); + assert!(compact.contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), "{code}"); + assert!(compact.contains("ɵɵdefer(5,3,Parent_Defer_5_DepsFn,null,4)"), "{code}"); + assert!(!compact.contains("dependencies:"), "{code}"); + assert!( + compact.contains( + "setClassMetadataAsync(Parent,()=>[import(\"./lazy\").then((m)=>m.LazyCmp),\ + import(\"./other\").then((m)=>m.OtherCmp)],(LazyCmp,OtherCmp)=>" + ), + "{code}" + ); +} + +#[test] +fn eager_dependency_used_in_a_defer_block_is_listed_by_reference() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp], + template: ' @defer { }', +}) +export class Parent {} +", + ); + let code = &result.code; + let compact = compact(code); + assert_eager(code, "./lazy"); + assert!(compact.contains("constParent_Defer_2_DepsFn=()=>[LazyCmp];"), "{code}"); + assert!(compact.contains("ɵɵdefer(2,1,Parent_Defer_2_DepsFn)"), "{code}"); + assert!(compact.contains("ɵɵgetComponentDepsFactory(Parent,[LazyCmp])"), "{code}"); +} + +#[test] +fn import_referenced_elsewhere_is_listed_by_reference_next_to_a_lazy_one() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component, ViewChild } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, OtherCmp], + template: '@defer { }', +}) +export class Parent { @ViewChild(LazyCmp) lazy: any; } +", + ); + let code = &result.code; + assert!( + compact(code).contains( + "constParent_Defer_1_DepsFn=()=>[LazyCmp,import(\"./other\").then((m)=>m.OtherCmp)];" + ), + "{code}" + ); +} + +#[test] +fn same_file_component_used_in_a_defer_block_is_listed_by_reference() { + let dir = TempDir::new().unwrap(); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-local', template: 'local' }) +class LocalCmp {} + +@Component({ + selector: 'app-parent', + imports: [LocalCmp], + template: '@defer { }', +}) +export class Parent {} +", + ); + let code = &result.code; + let compact = compact(code); + assert!(compact.contains("constParent_Defer_1_DepsFn=()=>[LocalCmp];"), "{code}"); + assert!(compact.contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), "{code}"); + // Like ngc: the block's function provides `LocalCmp`, so it leaves + // `dependencies`. + assert!(!compact.contains("dependencies:"), "{code}"); + assert!(!code.contains("setClassMetadataAsync"), "{code}"); +} + +#[test] +fn defer_block_mixes_a_lazy_import_and_a_same_file_component() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +@Component({ selector: 'app-local', template: 'local' }) +export class LocalCmp {} + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, LocalCmp], + template: '@defer { }', +}) +export class Parent {} +", + ); + let code = &result.code; + let compact = compact(code); + assert!( + compact.contains( + "constParent_Defer_1_DepsFn=()=>[import(\"./lazy\").then((m)=>m.LazyCmp),LocalCmp];" + ), + "{code}" + ); + assert!(!compact.contains("dependencies:"), "{code}"); +} + +#[test] +fn nested_defer_block_placeholder_belongs_to_the_outer_block() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, OtherCmp], + template: '@defer { @defer (on viewport) { } @placeholder { } }', +}) +export class Parent {} +", + ); + let code = &result.code; + let compact = compact(code); + assert!( + compact + .contains("constParent_Defer_1_DepsFn=()=>[import(\"./lazy\").then((m)=>m.LazyCmp)];"), + "{code}" + ); + assert!( + compact.contains( + "constParent_Defer_0_Defer_2_DepsFn=()=>[import(\"./other\").then((m)=>m.OtherCmp)];" + ), + "{code}" + ); +} + +#[test] +fn class_metadata_lists_lazy_dependencies_block_by_block() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [OtherCmp, LazyCmp], + template: '@defer { } @defer (on viewport) { } @placeholder {

x

}', +}) +export class Parent {} +", + ); + let code = &result.code; + assert!( + compact(code).contains( + "setClassMetadataAsync(Parent,()=>[import(\"./lazy\").then((m)=>m.LazyCmp),\ + import(\"./other\").then((m)=>m.OtherCmp)],(LazyCmp,OtherCmp)=>" + ), + "{code}" + ); +} + +#[test] +fn defer_block_without_dependencies_has_no_resolver() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp], + template: '@defer {

x

} ', +}) +export class Parent {} +", + ); + let code = &result.code; + assert!(compact(code).contains("ɵɵdefer(1,0);"), "{code}"); + assert!(!code.contains("DepsFn"), "{code}"); +} + +#[test] +fn deps_fn_name_drops_only_the_first_template_suffix() { + // ngc names the function after `unit.fnName?.replace('_Template', '')`, + // which replaces the first match only. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ selector: 'app-a', imports: [LazyCmp], template: '@defer { }' }) +export class Foo {} + +@Component({ selector: 'app-b', imports: [OtherCmp], template: '@defer { }' }) +export class Foo_Template {} + +@Component({ + selector: 'app-c', + imports: [LazyCmp], + template: '@if (x) { @defer { } }', +}) +export class My_TemplateCmp { + x = true; +} +", + ); + let code = &result.code; + let compact = compact(code); + assert!(compact.contains("constFoo_Defer_1_DepsFn=()=>[LazyCmp];"), "{code}"); + assert!(compact.contains("ɵɵdefer(1,0,Foo_Defer_1_DepsFn)"), "{code}"); + assert!( + compact.contains("constFoo_Template_Defer_1_DepsFn=()=>[import(\"./other\")"), + "{code}" + ); + assert!(compact.contains("ɵɵdefer(1,0,Foo_Template_Defer_1_DepsFn)"), "{code}"); + assert!( + compact.contains("constMyCmp_Conditional_0_Template_Defer_1_DepsFn=()=>[LazyCmp];"), + "{code}" + ); + assert_eq!(code.matches("const Foo_Defer_1_DepsFn").count(), 1, "{code}"); +} + +#[test] +fn deps_fns_do_not_move_the_constant_names_of_later_components() { + // ngc pools the function under its own name (`getSharedFunctionReference` + // with `useUniqueName` false), which takes no counter value. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let source = |imports: &str| { + format!( + r#" +import {{ Component }} from '@angular/core'; +import {{ LazyCmp }} from './lazy'; + +@Component({{ selector: 'app-local', template: 'local' }}) +export class LocalCmp {{}} + +@Component({{ + selector: 'app-a', + imports: [{imports}], + template: '

@defer {{ }} @defer {{ }}', +}}) +export class A1 {{ + x = 1; +}} + +@Component({{ + selector: 'app-b', + imports: [LocalCmp], + template: '

@defer {{ }}', +}}) +export class A2 {{ + x = 1; +}} +"# + ) + }; + // The name of A2's first pure function. + let a2_constant = |code: &str| { + let compact = compact(code); + let end = compact.find("=(a0)=>[2,a0]").expect(code); + let start = compact[..end].rfind("const").expect(code) + "const".len(); + compact[start..end].to_string() + }; + let with_deps_fns = transform(&dir, &source("LazyCmp, LocalCmp")).code; + assert!(with_deps_fns.contains("const A1_Defer_2_DepsFn"), "{with_deps_fns}"); + assert!(with_deps_fns.contains("const A1_Defer_5_DepsFn"), "{with_deps_fns}"); + let without = transform(&dir, &source("")).code; + assert!(!without.contains("const A1_Defer"), "{without}"); + assert_eq!(a2_constant(&with_deps_fns), a2_constant(&without), "{with_deps_fns}"); +} + +#[test] +fn block_only_dependencies_leave_dependencies_of_each_component() { + // Like ngc, a same-file component used only inside `@defer` is provided + // by the block's function, not by `dependencies`. + let dir = TempDir::new().unwrap(); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-local', template: 'local' }) +export class LocalCmp {} + +@Component({ selector: 'app-a', imports: [LocalCmp], template: '@defer { }' }) +export class App1 {} + +@Component({ + selector: 'app-b', + imports: [LocalCmp], + template: ' @defer { }', +}) +export class App2 {} +", + ); + let code = &result.code; + let compact = compact(code); + assert!(compact.contains("constApp1_Defer_1_DepsFn=()=>[LocalCmp];"), "{code}"); + assert!(compact.contains("constApp2_Defer_2_DepsFn=()=>[LocalCmp];"), "{code}"); + assert!(!compact.contains("ɵɵgetComponentDepsFactory(App1"), "{code}"); + assert!(compact.contains("ɵɵgetComponentDepsFactory(App2,[LocalCmp])"), "{code}"); +}