diff --git a/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs b/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs index 6f535f591..ba419e63d 100644 --- a/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs +++ b/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs @@ -97,6 +97,34 @@ pub fn line_number_at(source: &str, offset: usize) -> u32 { line } +/// TypeScript's `pos` of `class`'s name: the end of the `class` keyword. +/// +/// The class span starts at the decorators of a class with no `export`, so +/// ngtsc's `lineNumber` is not the span start's line. Only modifiers, the +/// keyword, whitespace and comments sit between the last decorator and the +/// name. +pub fn class_name_pos(source: &str, class: &oxc_ast::ast::Class<'_>) -> u32 { + let Some(name) = &class.id else { return class.span.start }; + let start = class.decorators.last().map_or(class.span.start, |d| d.span.end) as usize; + let text = &source[..name.span.start as usize]; + let mut pos = start; + let mut i = start; + while i < text.len() { + let rest = &text[i..]; + if rest.starts_with("//") { + i += rest.find(['\n', '\r', '\u{2028}', '\u{2029}']).unwrap_or(rest.len()); + } else if let Some(comment) = rest.strip_prefix("/*") { + i += comment.find("*/").map_or(rest.len(), |end| end + 4); + } else if rest.starts_with(|c: char| c.is_ascii_alphabetic()) { + i += rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len()); + pos = i; + } else { + i += rest.chars().next().map_or(1, char::len_utf8); + } + } + u32::try_from(pos).unwrap_or(name.span.start) +} + #[cfg(test)] mod tests { use super::line_number_at; diff --git a/crates/oxc_angular_compiler/src/class_debug_info/mod.rs b/crates/oxc_angular_compiler/src/class_debug_info/mod.rs index 68148772f..3326e7590 100644 --- a/crates/oxc_angular_compiler/src/class_debug_info/mod.rs +++ b/crates/oxc_angular_compiler/src/class_debug_info/mod.rs @@ -18,4 +18,4 @@ mod compiler; mod metadata; pub use compiler::compile_class_debug_info; -pub use metadata::{R3ClassDebugInfo, line_number_at}; +pub use metadata::{R3ClassDebugInfo, class_name_pos, line_number_at}; 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 d0d9f81a1..9e8d64d98 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -19,8 +19,8 @@ use std::rc::{Rc, Weak}; use oxc_allocator::Allocator; use oxc_ast::ast::{ - BindingPattern, Class, Declaration, ExportDefaultDeclarationKind, Expression, - ObjectPropertyKind, Program, Statement, TSNamespaceDeclarationBody, + BindingPattern, Class, Declaration, ExportDefaultDeclarationKind, Expression, Program, + Statement, TSNamespaceDeclarationBody, }; use oxc_parser::Parser; use oxc_resolver::{ @@ -29,8 +29,13 @@ use oxc_resolver::{ use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; +use super::defer_resolver::{ClassUse, decorated_class_selector}; use crate::directive::{ImportValueResolver, StringConsts}; +/// `(file, exported_name, local_name, implicit_standalone)` -> how a +/// template uses the class (see `Inner::selector_memo`). +type SelectorMemo = FxHashMap<(String, String, String, bool), Option>; + /// Result of analyzing a file's exports #[derive(Debug, Clone)] pub struct ExportInfo { @@ -87,7 +92,7 @@ struct Inner { /// Memoized standalone class selectors: `(file, exported_name, local_name, /// implicit_standalone)` -> selector (see /// [`Self::standalone_directive_selector`]). - selector_memo: RefCell>>, + selector_memo: RefCell, /// Exports whose evaluation is in progress (circular `const` chains — /// `a.A = b.B; b.B = a.A` — resolve the cycle edge to opaque, like /// `analyzing` does for files). @@ -170,17 +175,17 @@ impl ImportValueResolver for CrossFileResolver { } } -/// The selector of the class `local` declares in `program` (`"default"` for -/// `export default class`) and exports as `exported`, when it is a -/// standalone `@Component` or `@Directive` whose `selector` and `standalone` -/// are literals. `consts` are the program's. +/// The [`ClassUse`] of the class `local` declares in `program` (`"default"` +/// for `export default class`) and exports as `exported`, when it is a +/// standalone `@Component`, `@Directive` or `@Pipe` whose `selector` (or +/// `name`) and `standalone` are literals. `consts` are the program's. fn standalone_class_selector<'a>( program: &'a Program<'a>, consts: &StringConsts<'a>, exported: &str, local: &str, implicit_standalone: bool, -) -> Option { +) -> Option { // A plain `class Local` must be exported as `exported` by a local // `export { Local as exported }`: the export map also records namespace // members, which can share the name of a top-level class the file doesn't @@ -213,68 +218,6 @@ fn standalone_class_selector<'a>( 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) - })?; - let Expression::CallExpression(call) = &decorator.expression else { return None }; - let Some(Expression::ObjectExpression(object)) = - call.arguments.first().and_then(|arg| arg.as_expression()) - else { - return None; - }; - - let mut selector = None; - let mut standalone = implicit_standalone; - for property in &object.properties { - let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; - // A computed key can override `selector` or `standalone`. - let key = property.key.static_name()?; - if key == "selector" { - let Expression::StringLiteral(value) = &property.value else { return None }; - selector = Some(value.value.as_str()?.to_string()); - } else if key == "standalone" { - let Expression::BooleanLiteral(value) = &property.value else { return None }; - standalone = value.value; - } - } - selector.filter(|selector| standalone && !selector.trim().is_empty()) -} - impl CrossFileAnalyzer { /// Create a new cross-file analyzer. /// @@ -354,10 +297,13 @@ impl CrossFileAnalyzer { self.inner.resolve_export_value(import_source, import_name, from_file) } - /// The selector of the standalone `@Component` or `@Directive` class that - /// `import_name` is exported as from `import_source`, as `import_source` - /// is written in `from_file`, following named re-exports. Used to tell - /// whether a template matches the class, for `@defer` (#313). + /// The selector of the standalone `@Component` or `@Directive` class (or + /// the name of the standalone `@Pipe`) that `import_name` is exported as + /// from `import_source`, as `import_source` is written in `from_file`, + /// following named re-exports. Used to tell whether a template uses the + /// class, for `@defer` (#313). It comes + /// with the file that declares the class and the class's name there, + /// which tell two references to one class apart from two classes. /// /// `None` when the import can't be followed to a class declared in a /// source file (packages, `export *`, unresolvable paths), the class isn't @@ -370,7 +316,7 @@ impl CrossFileAnalyzer { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option { + ) -> Option<(ClassUse, (String, String))> { self.inner.standalone_directive_selector( import_source, import_name, @@ -539,7 +485,7 @@ impl Inner { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option { + ) -> Option<(ClassUse, (String, String))> { let mut file = self.resolve_module_spec(from_file.parent()?, import_source)?; let mut name = import_name.to_string(); for _ in 0..MAX_EXPORT_CHAIN { @@ -556,12 +502,10 @@ impl Inner { let info = self.cache.borrow().get(&file)?.get(&name).cloned()?; match info.re_export_source { None => { - return self.class_selector( - &file, - &name, - info.value_local.as_deref()?, - implicit_standalone, - ); + let local = info.value_local?; + let selector = + self.class_selector(&file, &name, &local, implicit_standalone)?; + return Some((selector, (file, local))); } Some((source, original)) => { if original == "*" { @@ -583,7 +527,7 @@ impl Inner { exported: &str, local: &str, implicit_standalone: bool, - ) -> Option { + ) -> Option { let key = (file_path.to_string(), exported.to_string(), local.to_string(), implicit_standalone); if let Some(cached) = self.selector_memo.borrow().get(&key) { @@ -2089,18 +2033,21 @@ export { doSomething } from './utils'; let main_file = dir.path().join("main.ts"); let mut analyzer = CrossFileAnalyzer::new(dir.path(), None, true); let selector = |analyzer: &CrossFileAnalyzer| { - analyzer.standalone_directive_selector("./lazy", "LazyCmp", &main_file, true) + analyzer + .standalone_directive_selector("./lazy", "LazyCmp", &main_file, true) + .map(|(selector, _)| selector) }; - assert_eq!(selector(&analyzer).as_deref(), Some("app-a")); + let app = |selector: &str| Some(ClassUse::Selector(selector.to_string())); + assert_eq!(selector(&analyzer), app("app-a")); assert_eq!(analyzer.take_value_dependencies().len(), 1); fs::write(&lazy, component.replace("app-a", "app-b")).unwrap(); - assert_eq!(selector(&analyzer).as_deref(), Some("app-a")); + assert_eq!(selector(&analyzer), app("app-a")); assert_eq!(analyzer.take_value_dependencies().len(), 1); analyzer.clear_cache(); - assert_eq!(selector(&analyzer).as_deref(), Some("app-b")); + assert_eq!(selector(&analyzer), app("app-b")); } #[test] diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 3722ee90c..aa2b69c0a 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -5,15 +5,15 @@ use oxc_allocator::{Allocator, Vec}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Class, ClassElement, Decorator, Expression, - MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, PropertyKey, + Argument, ArrayExpressionElement, BindingPattern, Class, ClassElement, Declaration, Decorator, + Expression, MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, PropertyKey, Statement, }; use oxc_span::Span; use oxc_str::Ident; use super::dependency::R3DependencyMetadata; use super::metadata::{ - ChangeDetectionStrategy, ComponentMetadata, HostDirectiveMetadata, HostMetadata, + ChangeDetectionStrategy, ComponentMetadata, HostDirectiveMetadata, HostMetadata, ImportRef, TemplateDependency, ViewEncapsulation, }; use super::transform::ImportMap; @@ -66,6 +66,9 @@ pub fn extract_component_metadata<'a>( // Create metadata with defaults (standalone uses implicit_standalone when not explicitly set) let mut metadata = ComponentMetadata::new(allocator, class_name, class_span, implicit_standalone); + if let Some(source) = source_text { + metadata.class_name_pos = crate::class_debug_info::class_name_pos(source, class); + } // Whether `imports` and `deferredImports` are provably empty; a spread or // accessor this loop skips could add them. @@ -182,6 +185,7 @@ pub fn extract_component_metadata<'a>( metadata.imports = extract_identifier_array(allocator, &prop.value); metadata.import_entry_classes = extract_import_entry_classes(allocator, &prop.value, consts); + metadata.import_refs = extract_import_refs(allocator, &prop.value, consts); // 2. The raw expression to pass to ɵɵgetComponentDepsFactory in RuntimeResolved mode metadata.raw_imports = convert_oxc_expression(allocator, &prop.value, source_text); @@ -514,6 +518,108 @@ fn extract_import_entry_classes<'a>( result } +/// The classes an `imports` value names, as ngtsc's partial evaluator +/// flattens it: the classes [`extract_import_entry_classes`] reads from an +/// array literal, through nested arrays, spreads and same-file `const` arrays +/// (`imports: IMPORTS`), and `ns.Cmp` members. The spans are those of the +/// identifiers where they are written: in a `const`'s array, every component +/// naming it shares them. Anything else is left out. +fn extract_import_refs<'a>( + allocator: &'a Allocator, + expr: &'a Expression<'a>, + consts: &StringConsts<'a>, +) -> Vec<'a, ImportRef<'a>> { + let mut refs = Vec::new_in(&allocator); + collect_import_refs(allocator, expr, consts, &mut refs, 0); + refs +} + +fn collect_import_refs<'a>( + allocator: &'a Allocator, + expr: &'a Expression<'a>, + consts: &StringConsts<'a>, + refs: &mut Vec<'a, ImportRef<'a>>, + depth: usize, +) { + // `const A = [...B]; const B = [...A];` never ends. + if depth > 16 { + return; + } + let unwrapped = try_unwrap_forward_ref(expr, Some(consts)); + let is_forward_reference = unwrapped.is_some(); + match unwrapped.unwrap_or(expr) { + Expression::ArrayExpression(arr) => { + for element in &arr.elements { + let element = match element { + ArrayExpressionElement::SpreadElement(spread) => &spread.argument, + element => match element.as_expression() { + Some(element) => element, + None => continue, + }, + }; + collect_import_refs(allocator, element, consts, refs, depth + 1); + } + } + Expression::Identifier(id) => match top_level_const_init(consts, &id.name) { + Some(init @ Expression::ArrayExpression(_)) => { + collect_import_refs(allocator, init, consts, refs, depth + 1); + } + _ => refs.push(ImportRef { + key: id.name, + namespace: None, + name: id.name, + span: id.span, + is_forward_reference, + }), + }, + Expression::StaticMemberExpression(member) => { + if let Expression::Identifier(namespace) = &member.object { + let key = format!("{}.{}", namespace.name, member.property.name); + refs.push(ImportRef { + key: Ident::from(allocator.alloc_str(&key)), + namespace: Some(namespace.name), + name: member.property.name, + span: member.property.span, + is_forward_reference, + }); + } + } + _ => {} + } +} + +/// The initializer of the top-level variable (`const`, `let` or `var`, +/// exported or not) that binds `name` in the file `consts` were collected +/// from. Like ngtsc's partial evaluator, it reads through `as const` and +/// through a variable that names another one (`const IMPORTS = BASE`). +fn top_level_const_init<'a>(consts: &StringConsts<'a>, name: &str) -> Option<&'a Expression<'a>> { + let mut name = name; + // `const A = B; const B = A;` never ends. + for _ in 0..16 { + let init = consts.program()?.body.iter().find_map(|stmt| { + let decl = match stmt { + Statement::VariableDeclaration(decl) => decl, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(decl) => decl, + _ => return None, + }, + _ => return None, + }; + decl.declarations.iter().find_map(|declarator| match &declarator.id { + BindingPattern::BindingIdentifier(id) if id.name == name => { + declarator.init.as_ref() + } + _ => None, + }) + })?; + match init.get_inner_expression() { + Expression::Identifier(id) => name = id.name.as_str(), + init => return Some(init), + } + } + None +} + /// Extract ViewEncapsulation from an expression. fn extract_encapsulation(expr: &Expression<'_>) -> ViewEncapsulation { // Look for patterns like: diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index 9195b5835..03c619efa 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -39,26 +39,34 @@ //! ([`defer_block_dependencies`], [`compile_defer_block_resolver`]). use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Class, Declaration, ExportDefaultDeclarationKind, Expression, ObjectPropertyKind, Program, + Statement, +}; +use crate::ast::expression::AngularExpression; use crate::ast::expression::BindingType; use crate::ast::r3::{ - R3BoundAttribute, R3BoundEvent, R3Component, R3DeferredBlock, R3Directive, R3Element, R3Node, - R3Template, R3TemplateAttr, R3TextAttribute, R3Visitor, visit_all, + R3BoundAttribute, R3BoundEvent, R3BoundText, R3Component, R3DeferredBlock, R3Directive, + R3Element, R3ForLoopBlock, R3Icu, R3IcuPlaceholder, R3IfBlockBranch, R3LetDeclaration, R3Node, + R3SwitchBlock, R3SwitchBlockCase, R3Template, R3TemplateAttr, R3TextAttribute, R3Visitor, + visit_all, }; use crate::class_metadata::{ R3DeferPerComponentDependency, compile_component_metadata_async_resolver, compile_deferred_dependency_import, }; +use crate::directive::StringConsts; use crate::i18n::serializer::is_i18n_attribute; use crate::output::ast::{ - ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, OutputExpression, ReadVarExpr, - WrappedNodeExpr, + ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, OutputExpression, ReadPropExpr, + ReadVarExpr, WrappedNodeExpr, }; use crate::parser::html::split_ns_name; use crate::pipeline::selector::CssSelector; use oxc_str::Ident; -use super::metadata::ComponentMetadata; +use super::metadata::{ComponentMetadata, ImportRef}; use super::transform::ImportMap; /// Returns `true` if any `@defer` block exists anywhere in the template. @@ -210,12 +218,111 @@ pub fn build_defer_resolver_expression<'a>( Some(compile_component_metadata_async_resolver(allocator, &deps)) } +/// The [`ClassUse`] of the top-level class `local` declares in `program`, +/// exported or not, when it is a standalone `@Component`, `@Directive` or +/// `@Pipe` whose `selector` (or `name`) and `standalone` are literals: an +/// `imports` entry that names a class of the file being compiled. `consts` +/// are the program's. +pub(super) 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) +} + +/// How a template uses a standalone class an `imports` entry names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClassUse { + /// A component or directive, matched by its selector. + Selector(String), + /// A pipe, by its name. + Pipe(String), +} + +/// The [`ClassUse`] of `class` when it is a standalone `@Component` or +/// `@Directive` whose `selector` and `standalone` are literals, or a +/// standalone `@Pipe` whose `name` and `standalone` are. +pub(super) fn decorated_class_selector<'a>( + class: &Class<'a>, + consts: &StringConsts<'a>, + implicit_standalone: bool, +) -> Option { + let (decorator, name_key) = + [("Component", "selector"), ("Directive", "selector"), ("Pipe", "name")].iter().find_map( + |(name, key)| { + crate::directive::find_angular_class_decorator(&class.decorators, name, consts) + .map(|decorator| (decorator, *key)) + }, + )?; + let Expression::CallExpression(call) = &decorator.expression else { return None }; + let Some(Expression::ObjectExpression(object)) = + call.arguments.first().and_then(|arg| arg.as_expression()) + else { + return None; + }; + + let mut selector = None; + let mut standalone = implicit_standalone; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + // A computed key can override `selector` or `standalone`. + let key = property.key.static_name()?; + if key == name_key { + let Expression::StringLiteral(value) = &property.value else { return None }; + selector = Some(value.value.as_str()?.to_string()); + } else if key == "standalone" { + let Expression::BooleanLiteral(value) = &property.value else { return None }; + standalone = value.value; + } + } + let selector = selector.filter(|selector| standalone && !selector.trim().is_empty())?; + Some(if name_key == "name" { ClassUse::Pipe(selector) } else { ClassUse::Selector(selector) }) +} + +/// What a template matches against an `imports` class to tell whether it +/// uses it. +pub(super) enum CandidateUse { + /// The parsed selector of a component or directive. + Selectors(std::vec::Vec), + /// The name of a pipe. + Pipe(String), +} + +impl CandidateUse { + pub(super) fn of(class_use: &ClassUse) -> Self { + match class_use { + ClassUse::Selector(selector) => Self::Selectors(CssSelector::parse(selector)), + ClassUse::Pipe(name) => Self::Pipe(name.clone()), + } + } +} + +/// An `imports` class `@defer` may lazy-load, by the name it is known by. +pub(super) type Candidate<'a> = (Ident<'a>, CandidateUse); + /// The `candidates` matched only inside the main content of `@defer` blocks /// (`resolveDeferBlocks` in ngc's component `handler.ts`). `@placeholder`, /// `@loading` and `@error` count as eager. Selectorless templates return none. pub(super) fn implicitly_deferred_imports<'a>( nodes: &[R3Node<'a>], - candidates: &[(Ident<'a>, std::vec::Vec)], + candidates: &[Candidate<'a>], ) -> std::vec::Vec> { let mut matcher = CandidateMatcher::new(candidates); visit_all(&mut matcher, nodes); @@ -239,10 +346,10 @@ pub(super) fn implicitly_deferred_imports<'a>( /// 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)], + candidates: &[Candidate<'a>], ) -> Option>)>> { struct BlockCollector<'c, 'a> { - candidates: &'c [(Ident<'a>, std::vec::Vec)], + candidates: &'c [Candidate<'a>], blocks: std::vec::Vec<(oxc_span::Span, std::vec::Vec>)>, } @@ -291,15 +398,15 @@ pub(super) fn defer_block_dependencies<'a>( /// ngc's plain reference is the `WrappedNodeExpr` its `LocalIdentifierStrategy` /// emits (`Reference.getIdentityIn`): the name of a class declared in the /// file, which is the same node for every component and stays a plain read -/// here, or for an imported class the identifier in this component's -/// `imports`, found in `import_entry_classes`. Wrapped nodes are equal only -/// when they are the same node, so the constant pool shares a function that -/// reads an imported class within this component only. +/// here, or for an imported class the identifier it was read from, found in +/// `import_refs`. Wrapped nodes are equal only when they are the same node, +/// so the constant pool shares a function that reads an imported class +/// within this component only, unless the components name one `const` array. pub(super) fn compile_defer_block_resolver<'a>( allocator: &'a Allocator, dependencies: &[Ident<'a>], deferred: &[Ident<'a>], - import_entry_classes: &[Option<(Ident<'a>, oxc_span::Span)>], + import_refs: &[ImportRef<'a>], import_map: &ImportMap<'a>, ) -> Option> { if dependencies.is_empty() { @@ -314,19 +421,33 @@ pub(super) fn compile_defer_block_resolver<'a>( } else { None }; - let imports_identifier = import_entry_classes - .iter() - .flatten() - .find(|(class, _)| class == name && import_map.contains_key(name)); - entries.push(deferrable.unwrap_or_else(|| match imports_identifier { - Some((_, span)) => OutputExpression::WrappedNode(oxc_allocator::Box::new_in( - WrappedNodeExpr { node_id: *name, source_span: Some(*span) }, - &allocator, - )), - None => OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: *name, source_span: None }, + let import = import_refs.iter().find(|import| import.key == *name); + let read_var = |name: Ident<'a>| { + OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name, source_span: None }, &allocator, - )), + )) + }; + entries.push(deferrable.unwrap_or_else(|| match import { + // ngc emits `ns.Cmp` through an import of its own (`i1.Cmp`). + Some(&ImportRef { namespace: Some(namespace), name, .. }) => { + OutputExpression::ReadProp(oxc_allocator::Box::new_in( + ReadPropExpr { + receiver: oxc_allocator::Box::new_in(read_var(namespace), &allocator), + name, + optional: false, + source_span: None, + }, + &allocator, + )) + } + Some(import) if import_map.contains_key(name) => { + OutputExpression::WrappedNode(oxc_allocator::Box::new_in( + WrappedNodeExpr { node_id: *name, source_span: Some(import.span) }, + &allocator, + )) + } + _ => read_var(*name), })); } let array = OutputExpression::LiteralArray(oxc_allocator::Box::new_in( @@ -347,7 +468,7 @@ pub(super) fn compile_defer_block_resolver<'a>( /// 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)], + candidates: &'c [Candidate<'a>], in_defer: bool, deferred: std::vec::Vec, eager: std::vec::Vec, @@ -355,7 +476,7 @@ struct CandidateMatcher<'c, 'a> { } impl<'c, 'a> CandidateMatcher<'c, 'a> { - fn new(candidates: &'c [(Ident<'a>, std::vec::Vec)]) -> Self { + fn new(candidates: &'c [Candidate<'a>]) -> Self { Self { candidates, in_defer: false, @@ -366,18 +487,101 @@ impl<'c, 'a> CandidateMatcher<'c, 'a> { } 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; + for (index, (_, class_use)) in self.candidates.iter().enumerate() { + if let CandidateUse::Selectors(selectors) = class_use + && selectors.iter().any(|selector| selector.matches(element)) + { + self.mark(index); + } + } + } + + /// Records the pipes `expr` uses (`visitPipe` in t2_binder's + /// `TemplateBinder`). + fn record_pipes(&mut self, expr: &AngularExpression<'_>) { + visit_pipe_names(expr, &mut |name| { + for (index, (_, class_use)) in self.candidates.iter().enumerate() { + if matches!(class_use, CandidateUse::Pipe(pipe) if pipe == name) { + self.mark(index); } } + }); + } + + fn mark(&mut self, index: usize) { + if self.in_defer { + self.deferred[index] = true; + } else { + self.eager[index] = true; } } } +/// Calls `f` with the name of each pipe `expr` uses. +fn visit_pipe_names(expr: &AngularExpression<'_>, f: &mut dyn FnMut(&str)) { + let all = |exprs: &[AngularExpression<'_>], f: &mut dyn FnMut(&str)| { + for expr in exprs { + visit_pipe_names(expr, f); + } + }; + match expr { + AngularExpression::BindingPipe(pipe) => { + f(&pipe.name); + visit_pipe_names(&pipe.exp, f); + all(&pipe.args, f); + } + AngularExpression::Binary(b) => { + visit_pipe_names(&b.left, f); + visit_pipe_names(&b.right, f); + } + AngularExpression::Conditional(c) => { + visit_pipe_names(&c.condition, f); + visit_pipe_names(&c.true_exp, f); + visit_pipe_names(&c.false_exp, f); + } + AngularExpression::PropertyRead(p) => visit_pipe_names(&p.receiver, f), + AngularExpression::SafePropertyRead(p) => visit_pipe_names(&p.receiver, f), + AngularExpression::KeyedRead(k) => { + visit_pipe_names(&k.receiver, f); + visit_pipe_names(&k.key, f); + } + AngularExpression::SafeKeyedRead(k) => { + visit_pipe_names(&k.receiver, f); + visit_pipe_names(&k.key, f); + } + AngularExpression::Call(c) => { + visit_pipe_names(&c.receiver, f); + all(&c.args, f); + } + AngularExpression::SafeCall(c) => { + visit_pipe_names(&c.receiver, f); + all(&c.args, f); + } + AngularExpression::PrefixNot(p) => visit_pipe_names(&p.expression, f), + AngularExpression::Unary(u) => visit_pipe_names(&u.expr, f), + AngularExpression::TypeofExpression(t) => visit_pipe_names(&t.expression, f), + AngularExpression::VoidExpression(v) => visit_pipe_names(&v.expression, f), + AngularExpression::NonNullAssert(n) => visit_pipe_names(&n.expression, f), + AngularExpression::ParenthesizedExpression(p) => visit_pipe_names(&p.expression, f), + AngularExpression::SpreadElement(s) => visit_pipe_names(&s.expression, f), + AngularExpression::ArrowFunction(a) => visit_pipe_names(&a.body, f), + AngularExpression::LiteralArray(a) => all(&a.expressions, f), + AngularExpression::LiteralMap(m) => all(&m.values, f), + AngularExpression::Chain(c) => all(&c.expressions, f), + AngularExpression::Interpolation(i) => all(&i.expressions, f), + AngularExpression::TaggedTemplateLiteral(t) => { + visit_pipe_names(&t.tag, f); + all(&t.template.expressions, f); + } + AngularExpression::TemplateLiteral(t) => all(&t.expressions, f), + AngularExpression::Empty(_) + | AngularExpression::ImplicitReceiver(_) + | AngularExpression::ThisReceiver(_) + | AngularExpression::LiteralPrimitive(_) + | AngularExpression::RegularExpressionLiteral(_) => {} + } +} + impl<'a> R3Visitor<'a> for CandidateMatcher<'_, 'a> { fn visit_element(&mut self, element: &R3Element<'a>) { if !element.directives.is_empty() { @@ -397,10 +601,77 @@ impl<'a> R3Visitor<'a> for CandidateMatcher<'_, 'a> { self.selectorless = true; } self.record(&template_selector(template)); + for attr in &template.template_attrs { + if let R3TemplateAttr::Bound(attr) = attr { + self.record_pipes(&attr.value); + } + } self.visit_template_children(template); } + fn visit_bound_text(&mut self, text: &R3BoundText<'a>) { + self.record_pipes(&text.value); + } + + fn visit_bound_attribute(&mut self, attr: &R3BoundAttribute<'a>) { + self.record_pipes(&attr.value); + } + + fn visit_bound_event(&mut self, event: &R3BoundEvent<'a>) { + self.record_pipes(&event.handler); + } + + fn visit_icu(&mut self, icu: &R3Icu<'a>) { + for (_, var) in &icu.vars { + self.record_pipes(&var.value); + } + for (_, placeholder) in &icu.placeholders { + if let R3IcuPlaceholder::BoundText(text) = placeholder { + self.record_pipes(&text.value); + } + } + } + + fn visit_if_block_branch(&mut self, branch: &R3IfBlockBranch<'a>) { + if let Some(expression) = &branch.expression { + self.record_pipes(expression); + } + visit_all(self, &branch.children); + } + + fn visit_for_loop_block(&mut self, block: &R3ForLoopBlock<'a>) { + self.record_pipes(&block.expression.ast); + self.record_pipes(&block.track_by.ast); + visit_all(self, &block.children); + if let Some(empty) = &block.empty { + self.visit_for_loop_block_empty(empty); + } + } + + fn visit_switch_block(&mut self, block: &R3SwitchBlock<'a>) { + self.record_pipes(&block.expression); + for group in &block.groups { + self.visit_switch_block_case_group(group); + } + } + + fn visit_switch_block_case(&mut self, case: &R3SwitchBlockCase<'a>) { + if let Some(expression) = &case.expression { + self.record_pipes(expression); + } + } + + fn visit_let_declaration(&mut self, decl: &R3LetDeclaration<'a>) { + self.record_pipes(&decl.value); + } + fn visit_deferred_block(&mut self, block: &R3DeferredBlock<'a>) { + // Its `when` conditions belong to the enclosing template. + for triggers in [&block.triggers, &block.prefetch_triggers, &block.hydrate_triggers] { + if let Some(when) = &triggers.when { + self.record_pipes(&when.value); + } + } let outer = self.in_defer; self.in_defer = true; for child in &block.children { diff --git a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs index c7e212506..ac09dcd5e 100644 --- a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs +++ b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs @@ -19,8 +19,7 @@ use std::hash::{DefaultHasher, Hasher}; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, Class, Declaration, ExportDefaultDeclarationKind, Expression, ObjectPropertyKind, - Program, PropertyKey, PropertyKind, Statement, + Argument, Class, Expression, ObjectPropertyKind, Program, PropertyKey, PropertyKind, }; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType, Span}; @@ -78,10 +77,18 @@ pub(super) fn hmr_fingerprint_of_program( ) -> ComponentHmrFingerprint { let mut components = Vec::new(); let mut excluded = Vec::new(); - for stmt in &program.body { - let Some(class) = top_level_class(stmt) else { continue }; + // Every class the transform compiles, nested ones too. A name two classes + // declare compiles without HMR, so neither is read, nor is a `namespace` + // member. + let declared_classes = crate::util::declared_classes(program); + let ambiguous = super::transform::ambiguous_class_names(&declared_classes); + for declared in &declared_classes { + let class = declared.class; let Some(id) = &class.id else { continue }; - if !is_compiled(id.name.as_str()) { + if declared.namespace.is_some() + || ambiguous.contains(id.name.as_str()) + || !is_compiled(id.name.as_str()) + { continue; } if collect_excluded_ranges(class, consts, &mut excluded) { @@ -92,22 +99,6 @@ pub(super) fn hmr_fingerprint_of_program( fingerprint_with_exclusions(source, components, excluded) } -/// A class declared at the top level, the places the transform compiles. -fn top_level_class<'a>(stmt: &'a Statement<'a>) -> Option<&'a Class<'a>> { - match stmt { - Statement::ClassDeclaration(class) => Some(class), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class), - _ => None, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => Some(class), - _ => None, - }, - _ => None, - } -} - /// Push the inside of each literal `template` / `styles` value in `class`'s /// `@Component({...})`. Returns whether the class has that decorator with an /// object literal argument. diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index eceabcd5a..761cc40a7 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -257,45 +257,16 @@ impl<'a> ImportElisionAnalyzer<'a> { return FxHashSet::default(); } + // Classes nested in a function body or block compile too. let mut names = FxHashSet::default(); - for stmt in &program.body { - match stmt { - Statement::ClassDeclaration(class) => { - Self::collect_ctor_type_uses_from_class( - class, - &default_imports, - consts, - emit_metadata, - &mut names, - ); - } - Statement::ExportDefaultDeclaration(export) => { - if let oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class) = - &export.declaration - { - Self::collect_ctor_type_uses_from_class( - class, - &default_imports, - consts, - emit_metadata, - &mut names, - ); - } - } - Statement::ExportDeclaration(export) => { - if let oxc_ast::ast::Declaration::ClassDeclaration(class) = &export.declaration - { - Self::collect_ctor_type_uses_from_class( - class, - &default_imports, - consts, - emit_metadata, - &mut names, - ); - } - } - _ => {} - } + for declared in crate::util::declared_classes(program) { + Self::collect_ctor_type_uses_from_class( + declared.class, + &default_imports, + consts, + emit_metadata, + &mut names, + ); } names } @@ -603,19 +574,9 @@ impl<'a> ImportElisionAnalyzer<'a> { } } let mut references = References::default(); - for stmt in &program.body { - let class = match stmt { - Statement::ClassDeclaration(class) => class, - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class) => class, - _ => continue, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - oxc_ast::ast::Declaration::ClassDeclaration(class) => class, - _ => continue, - }, - _ => continue, - }; + // Classes nested in a function body or block compile too. + for declared in crate::util::declared_classes(program) { + let class = declared.class; if !class.decorators.iter().any(|decorator| { crate::directive::angular_class_decorator(decorator, consts).is_some() }) { diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index e828e4b01..b9c5acc8f 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -501,6 +501,10 @@ pub struct ComponentMetadata<'a> { /// The span of the class declaration. pub class_span: Span, + /// Where TypeScript puts the class name's `pos`: the end of the `class` + /// keyword. `setClassDebugInfo`'s `lineNumber` is its line. + pub class_name_pos: u32, + /// The CSS selector for this component. pub selector: Option>, @@ -550,6 +554,15 @@ pub struct ComponentMetadata<'a> { /// comes with the span of its identifier in the array. pub import_entry_classes: Vec<'a, Option<(Ident<'a>, Span)>>, + /// The classes `imports` names, flattened the way ngtsc's partial + /// evaluator resolves it without type information: through nested arrays, + /// spreads and same-file `const` arrays (`imports: IMPORTS`), and with + /// `ns.Cmp` members. Partial compilation lists them in `dependencies`. Unlike [`Self::import_entry_classes`], these may come + /// from outside the array literal, so `@defer` lists them in its blocks' + /// dependency functions but lazy-loads only the classes that one of + /// those entries names. + pub import_refs: Vec<'a, ImportRef<'a>>, + /// Exported names for template references. /// /// In Angular, `exportAs` can be a comma-separated string (e.g., "foo, bar"), @@ -707,6 +720,24 @@ pub struct ComponentMetadata<'a> { pub is_signal: bool, } +/// A class an `@Component.imports` value names (see +/// [`ComponentMetadata::import_refs`]). +#[derive(Debug, Clone, Copy)] +pub struct ImportRef<'a> { + /// The name `@defer` knows the reference by: `name`, or `ns.name`. + pub key: Ident<'a>, + /// The `import * as ns` binding of an `ns.name` member. + pub namespace: Option>, + /// The identifier that names the class. + pub name: Ident<'a>, + /// The span of that identifier. ngtsc emits a reference to an imported + /// class as a `WrappedNodeExpr` of the identifier it was read from, so + /// two references are equivalent only when they come from the same node. + pub span: Span, + /// Whether it is written as `forwardRef(() => name)`. + pub is_forward_reference: bool, +} + /// Metadata for a host directive. /// /// Corresponds to TypeScript's `HostDirectiveDef` in the compiler. @@ -854,6 +885,9 @@ pub struct TemplateDependency<'a> { /// Whether this is a forward reference. pub is_forward_reference: bool, + + /// The `import * as ns` binding the type is read through (`ns.Cmp`). + pub type_namespace: Option>, } impl<'a> TemplateDependency<'a> { @@ -875,6 +909,7 @@ impl<'a> TemplateDependency<'a> { is_component, pipe_name: None, is_forward_reference: false, + type_namespace: None, } } @@ -895,6 +930,7 @@ impl<'a> TemplateDependency<'a> { is_component: false, pipe_name: Some(pipe_name), is_forward_reference: false, + type_namespace: None, } } @@ -904,6 +940,13 @@ impl<'a> TemplateDependency<'a> { self } + /// Read the type through the `import * as ns` binding `namespace`. + #[must_use] + pub fn with_type_namespace(mut self, namespace: Ident<'a>) -> Self { + self.type_namespace = Some(namespace); + self + } + /// Set the source module for this dependency. pub fn with_source_module(mut self, source_module: Ident<'a>) -> Self { self.source_module = Some(source_module); @@ -966,6 +1009,7 @@ impl<'a> ComponentMetadata<'a> { Self { class_name, class_span, + class_name_pos: class_span.start, selector: None, template: None, template_span: None, @@ -979,6 +1023,7 @@ impl<'a> ComponentMetadata<'a> { host: None, imports: Vec::new_in(&allocator), import_entry_classes: Vec::new_in(&allocator), + import_refs: Vec::new_in(&allocator), export_as: Vec::new_in(&allocator), preserve_whitespaces: None, constructor_deps: None, diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 213d9919a..8683a5c80 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -36,8 +36,8 @@ pub use hmr_fingerprint::{ComponentHmrFingerprint, component_hmr_fingerprint}; pub use import_elision::ImportElisionAnalyzer; pub use metadata::{ AngularVersion, ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, - HostDirectiveMetadata, HostMetadata, LifecycleMetadata, Prerelease, TemplateDependency, - TemplateDependencyKind, ViewEncapsulation, + HostDirectiveMetadata, HostMetadata, ImportRef, LifecycleMetadata, Prerelease, + TemplateDependency, TemplateDependencyKind, ViewEncapsulation, }; pub use namespace_registry::NamespaceRegistry; pub use transform::{ diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 9c32b3cd0..cab8f8aa8 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -32,7 +32,7 @@ use super::decorator::{ use super::definition::{const_value_to_expression, generate_component_definitions}; use super::hoist::{collect_hoist_edits, program_has_angular_decorated_class}; use super::import_elision::{ImportElisionAnalyzer, import_elision_edits}; -use super::metadata::{AngularVersion, ComponentMetadata, HostMetadata}; +use super::metadata::{AngularVersion, ComponentMetadata, HostMetadata, ImportRef}; use super::namespace_registry::NamespaceRegistry; use super::signal_debug_name; use crate::ast::expression::{BindingType, ParsedEventType}; @@ -849,25 +849,6 @@ fn resolve_host_directive_namespaces<'a>( } } -/// Compute the effective start position for a class statement in the original source. -/// -/// This accounts for non-Angular decorators that remain on the class after Angular -/// decorator removal. The effective start is the minimum of `stmt_start` and the -/// earliest remaining (non-removed) decorator's start position. -fn compute_effective_start( - class: &oxc_ast::ast::Class, - decorator_spans_to_remove: &[Span], - stmt_start: u32, -) -> u32 { - class - .decorators - .iter() - .filter(|d| !decorator_spans_to_remove.contains(&d.span)) - .map(|d| d.span.start) - .min() - .map_or(stmt_start, |dec_start| dec_start.min(stmt_start)) -} - /// This is used to determine where to insert namespace imports so they appear /// AFTER existing imports but BEFORE other code (like class declarations). /// @@ -1644,6 +1625,112 @@ fn collect_decorator_identifiers<'a>( identifiers.0 } +/// Whether the Angular metadata of a class nested in a function or block (its +/// decorators, an initializer API's arguments, or the constructor's parameter +/// types) reads a binding that function or block declares, other than a class +/// the output names as is. +/// The decorator evaluator only sees the file's top-level bindings: it would +/// miss a local `const` that ngtsc folds, or fold the top-level binding a +/// local one shadows. +fn names_enclosing_binding( + class: &oxc_ast::ast::Class<'_>, + semantic: &oxc_semantic::Semantic<'_>, + string_consts: &crate::directive::StringConsts<'_>, +) -> bool { + use oxc_ast_visit::Visit; + struct LocalReads<'s> { + scoping: &'s oxc_semantic::Scoping, + class_span: Span, + // Reading a constructor parameter type: the DI token. + types: bool, + found: bool, + } + impl<'a> Visit<'a> for LocalReads<'_> { + fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { + let scoping = self.scoping; + let Some(reference) = it.reference_id.get().map(|id| scoping.get_reference(id)) else { + return; + }; + let Some(symbol) = reference.symbol_id().filter(|_| self.types || !reference.is_type()) + else { + return; + }; + // The class's own name and bindings inside the metadata + // (`useFactory: (dep) => ...`) are not the enclosing scope's. + let span = scoping.symbol_span(symbol); + if scoping.symbol_scope_id(symbol) == scoping.root_scope_id() + || (self.class_span.start <= span.start && span.end <= self.class_span.end) + { + return; + } + if !scoping.symbol_flags(symbol).is_class() + || scoping.get_root_binding(it.name).is_some() + { + self.found = true; + } + } + // The token is the type's name, not its type arguments. + fn visit_ts_type_reference(&mut self, it: &oxc_ast::ast::TSTypeReference<'a>) { + self.visit_ts_type_name(&it.type_name); + } + } + + let mut reads = LocalReads { + scoping: semantic.scoping(), + class_span: class.span, + types: false, + found: false, + }; + for decorator in &class.decorators { + reads.visit_decorator(decorator); + } + if let Some(constructor) = crate::util::find_constructor(class) { + reads.types = true; + for param in &constructor.value.params.items { + if let Some(annotation) = ¶m.type_annotation { + reads.visit_ts_type_annotation(annotation); + } + } + reads.types = false; + } + for element in &class.body.body { + match element { + oxc_ast::ast::ClassElement::MethodDefinition(method) => { + for decorator in &method.decorators { + reads.visit_decorator(decorator); + } + for param in &method.value.params.items { + for decorator in ¶m.decorators { + reads.visit_decorator(decorator); + } + } + } + oxc_ast::ast::ClassElement::PropertyDefinition(property) => { + for decorator in &property.decorators { + reads.visit_decorator(decorator); + } + if let Some(Expression::CallExpression(call)) = + property.value.as_ref().map(unwrap_jit_initializer) + && classify_initializer_api(&call.callee, string_consts).is_some() + { + // The callee too: an enclosing `input` shadows the + // imported one, and the field is no input then. + reads.visit_expression(&call.callee); + reads.visit_arguments(&call.arguments); + } + } + oxc_ast::ast::ClassElement::AccessorProperty(property) => { + for decorator in &property.decorators { + reads.visit_decorator(decorator); + } + } + oxc_ast::ast::ClassElement::StaticBlock(_) + | oxc_ast::ast::ClassElement::TSIndexSignature(_) => {} + } + } + reads.found +} + /// Every identifier a file has, like TypeScript's `SourceFile.identifiers`. #[derive(Default)] struct FileIdentifiers(rustc_hash::FxHashSet); @@ -3210,17 +3297,19 @@ pub fn transform_angular_file( options.emit_class_metadata && !options.advanced_optimizations, ); - // Collect class definitions by class name. - // Each entry is (class_name, static_definitions_to_insert, external_declarations) + // Collect class definitions by the class's span (classes nested in + // different functions may share a name). // The external_declarations are things like _c0 constants and child view functions that // go outside the class. - // We track by name because positions shift after import filtering. // (property_assignments, decls_before_class, decls_after_class) - let mut class_definitions: HashMap = HashMap::new(); + let mut class_definitions: HashMap = HashMap::new(); let mut decorator_spans_to_remove: Vec = Vec::new(); // Collect class positions from original AST for edit-based output. - // (class_name, effective_start, class_body_end) - let mut class_positions: Vec<(String, u32, u32)> = Vec::new(); + // (class span, class_body_end) + let mut class_positions: Vec<(Span, u32)> = Vec::new(); + // The compiled classes of a `namespace` body: ngtsc adds no statement + // after them there. + let mut namespace_compiled: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); // Classes that opted out of AOT via `jit: true` in their decorator // metadata: downleveled like JIT-mode classes (no Ivy definitions) while @@ -3275,9 +3364,9 @@ pub fn transform_angular_file( let mut shared_pool_index: u32 = 0; // Likewise, the literals the file's components pooled so far, and the // components whose ɵcmp has a long style inline that a later component - // may share: (class name, ɵcmp, its emitted text). + // may share: (class span, ɵcmp, its emitted text). let mut shared_pool = SharedPool::default(); - let mut inline_style_cmps: Vec<(String, OutputExpression<'_>, String)> = Vec::new(); + let mut inline_style_cmps: Vec<(Span, OutputExpression<'_>, String)> = Vec::new(); // The components compiled for HMR, whose inline template and styles the // HMR fingerprint leaves out. @@ -3453,29 +3542,39 @@ pub fn transform_angular_file( let program = &parser_ret.program; let string_consts = &string_consts; let implicit_standalone = options.implicit_standalone(); - 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)); + move |import: &ImportRef<'_>| -> Option { + let local = import.namespace.unwrap_or(import.name); + let Some(info) = import_map.get(&local) else { + return local_class(program, string_consts, import, implicit_standalone); + }; + // `ns.Cmp` reads the module's export; ngtsc never lazy-loads it. + let (export_name, is_imported) = match import.namespace { + Some(_) if info.is_named_import => return None, + Some(_) => (import.name.as_str(), false), + None if !info.is_named_import => return None, + None => (info.imported_name.as_deref().unwrap_or(&local), true), }; - if !info.is_named_import || info.is_type_only { + if info.is_type_only { return None; } - let export_name = info.imported_name.as_deref().unwrap_or(local); - let selector = analyzer.standalone_directive_selector( + let (class_use, declaration) = analyzer.standalone_directive_selector( info.source_module.as_str(), export_name, std::path::Path::new(path), implicit_standalone, )?; - Some((selector, true)) + Some(ImportedClass { class_use, is_imported, declaration }) } }); + // Without cross-file analysis, only the classes declared in this file + // have a selector to read: they need no other file, and are never + // lazy-loaded. + let local_selector = |import: &ImportRef<'_>| -> Option { + if import_map.contains_key(&import.namespace.unwrap_or(import.name)) { + return None; + } + local_class(&parser_ret.program, &string_consts, import, options.implicit_standalone()) + }; // ngc's `DeferredSymbolTracker` sees every component of the file before // any compiles. The `imports` entries another component marks are found // the first time a decision needs them, by class span. @@ -3483,24 +3582,40 @@ pub fn transform_angular_file( let deferrable_marks: std::cell::RefCell>>> = std::cell::RefCell::default(); - // 2. Walk AST to find @Component decorated classes and extract metadata - for stmt in &parser_ret.program.body { - let (class, stmt_start) = match stmt { - Statement::ClassDeclaration(class) => (Some(class.as_ref()), class.span.start), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => { - (Some(class.as_ref()), export.span.start) - } - _ => (None, 0), - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => (Some(class.as_ref()), export.span.start), - _ => (None, 0), - }, - _ => (None, 0), + // 2. Walk AST to find @Component decorated classes and extract metadata. + // ngtsc also compiles a class declared in a function body or block: its + // definitions stay on the class, and the constants and child template + // functions its compile declares go to the file top level. + let declared_classes = crate::util::declared_classes(&parser_ret.program); + // HMR finds a component by `{path}@{name}`, so a name two classes of the + // file declare (in two functions) names neither: they compile without it. + let hmr_options = options.hmr.then(|| TransformOptions { hmr: false, ..options.clone() }); + let ambiguous_names = ambiguous_class_names(&declared_classes); + for site in &declared_classes { + let class = site.class; + let stmt = site.top_level; + let stmt_start = if site.nested { class.span.start } else { stmt.span().start }; + // A nested `jit: true` class keeps its decorators, as does one whose + // metadata names a binding of the enclosing function: the evaluator + // only sees the file's top-level bindings. + let keeps_decorators = site.nested + && (find_jit_forced_decorator(class, &string_consts).is_some() + || names_enclosing_binding(class, &semantic, &string_consts)); + // A class in a function or block is not in the `.d.ts`; a `namespace` + // member is, under its dotted name: another class of the file may + // have its name. + let in_dts = !site.nested || site.namespace.is_some(); + let dts_entry = |mut declaration: dts::DtsDeclaration| { + if let Some(namespace) = &site.namespace { + declaration.class_name = format!("{namespace}.{}", declaration.class_name); + } + declaration }; + if !keeps_decorators { + if site.namespace.is_some() { + namespace_compiled.insert(class.span); + } - if let Some(class) = class { // Pre-flight: catch @Service co-located with another Angular // decorator before the primary-decorator branches dispatch. // Upstream service.ts:101-116 rejects this combination; without @@ -3755,10 +3870,20 @@ pub fn transform_angular_file( && let Some(import_selector) = &import_selector { let mut deferrable_marks = deferrable_marks.borrow_mut(); - for other in - parser_ret.program.body.iter().filter_map(statement_class) - { - if other.span == class.span { + // Every class the transform compiles, nested + // ones too. A nested class keeping its + // decorators keeps its `imports` entry. + for other in &declared_classes { + let other_nested = other.nested; + let other = other.class; + if other.span == class.span + || (other_nested + && names_enclosing_binding( + other, + &semantic, + &string_consts, + )) + { continue; } marks += marks_of( @@ -3783,11 +3908,28 @@ pub fn transform_angular_file( }; #[cfg(feature = "cross_file_elision")] let (import_selector, can_defer) = ( - import_selector.as_ref().map(|f| f as ImportSelector<'_>), - Some(&can_defer as CanDefer<'_>), + import_selector + .as_ref() + .map_or(&local_selector as ImportSelector<'_>, |f| f), + &can_defer as CanDefer<'_>, ); #[cfg(not(feature = "cross_file_elision"))] - let (import_selector, can_defer) = (None, None); + let can_defer = |_: &str, _: &[Ident<'_>]| false; + #[cfg(not(feature = "cross_file_elision"))] + let (import_selector, can_defer) = + (&local_selector as ImportSelector<'_>, &can_defer as CanDefer<'_>); + let (import_selector, can_defer) = (Some(import_selector), Some(can_defer)); + + // A `namespace` member has nowhere to put its HMR code. + let options = match &hmr_options { + Some(no_hmr) + if site.namespace.is_some() + || ambiguous_names.contains(class_name.as_str()) => + { + no_hmr + } + _ => options, + }; // 4. Compile the template and generate ɵcmp/ɵfac // Pass the shared pool index to ensure unique constant names @@ -4099,25 +4241,17 @@ pub fn transform_angular_file( } class_definitions.insert( - class_name.clone(), + class.span, (property_assignments, decls_before_class, decls_after_class), ); if let Some(cmp_definition) = compilation_result.cmp_definition.take() { inline_style_cmps.push(( - class_name.clone(), + class.span, cmp_definition, compilation_result.cmp_js.clone(), )); } - class_positions.push(( - class_name, - compute_effective_start( - class, - &decorator_spans_to_remove, - stmt_start, - ), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); // Generate .d.ts type declaration for this component let type_argument_count = class @@ -4131,15 +4265,19 @@ pub fn transform_angular_file( &parser_ret.program.comments, dts_core_namespace.as_str(), ); - result.dts_declarations.push(dts::generate_component_dts( - dts_core_namespace.as_str(), - &metadata, - type_argument_count, - &content_query_names, - has_injectable, - &compilation_result.ng_content_selectors, - &accept_types, - )); + if in_dts { + result.dts_declarations.push(dts_entry( + dts::generate_component_dts( + dts_core_namespace.as_str(), + &metadata, + type_argument_count, + &content_query_names, + has_injectable, + &compilation_result.ng_content_selectors, + &accept_types, + ), + )); + } result.component_count += 1; } @@ -4291,12 +4429,14 @@ pub fn transform_angular_file( &parser_ret.program.comments, dts_core_namespace.as_str(), ); - result.dts_declarations.push(dts::generate_directive_dts( - dts_core_namespace.as_str(), - &directive_metadata, - has_injectable, - &accept_types, - )); + if in_dts { + result.dts_declarations.push(dts_entry(dts::generate_directive_dts( + dts_core_namespace.as_str(), + &directive_metadata, + has_injectable, + &accept_types, + ))); + } // Emit setClassMetadata for TestBed support (overrideDirective + // signal members), mirroring the @Component path. @@ -4311,11 +4451,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class_name.clone(), - compute_effective_start(class, &decorator_spans_to_remove, stmt_start), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); // Constants pooled by the host bindings are declared before the class. let mut decls_before_class = String::new(); for stmt in &definitions.statements { @@ -4326,7 +4462,7 @@ pub fn transform_angular_file( } class_definitions.insert( - class_name, + class.span, (property_assignments, decls_before_class, decls_after_class), ); } else if let Some(mut pipe_metadata) = extract_pipe_metadata_in( @@ -4416,12 +4552,14 @@ pub fn transform_angular_file( // Generate .d.ts type declaration for this pipe let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); - result.dts_declarations.push(dts::generate_pipe_dts( - dts_core_namespace.as_str(), - &pipe_metadata, - type_argument_count, - has_injectable, - )); + if in_dts { + result.dts_declarations.push(dts_entry(dts::generate_pipe_dts( + dts_core_namespace.as_str(), + &pipe_metadata, + type_argument_count, + has_injectable, + ))); + } // Emit setClassMetadata for TestBed support (overridePipe). let decls_after_class = build_set_class_metadata_decls( @@ -4435,13 +4573,9 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class_name.clone(), - compute_effective_start(class, &decorator_spans_to_remove, stmt_start), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4544,12 +4678,14 @@ pub fn transform_angular_file( // Generate .d.ts type declaration for this NgModule let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); - result.dts_declarations.push(dts::generate_ng_module_dts( - dts_core_namespace.as_str(), - &ng_module_metadata, - type_argument_count, - has_injectable, - )); + if in_dts { + result.dts_declarations.push(dts_entry(dts::generate_ng_module_dts( + dts_core_namespace.as_str(), + &ng_module_metadata, + type_argument_count, + has_injectable, + ))); + } // Emit setClassMetadata for TestBed support (overrideModule). // ngtsc's `insertMetadataStatement` (ng_module/src/handler.ts) @@ -4573,13 +4709,9 @@ pub fn transform_angular_file( } // NgModule: external_decls go AFTER the class (they reference the class name) - class_positions.push(( - class_name.clone(), - compute_effective_start(class, &decorator_spans_to_remove, stmt_start), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), external_decls), ); } @@ -4640,6 +4772,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), &service_metadata, type_argument_count, + options.compilation_mode, ); let emitter = JsEmitter::new(); @@ -4651,11 +4784,13 @@ pub fn transform_angular_file( emitter.emit_expression(&definition.prov_definition) ); - result.dts_declarations.push(dts::generate_service_dts( - dts_core_namespace.as_str(), - &service_metadata, - type_argument_count, - )); + if in_dts { + result.dts_declarations.push(dts_entry(dts::generate_service_dts( + dts_core_namespace.as_str(), + &service_metadata, + type_argument_count, + ))); + } let decls_after_class = build_set_class_metadata_decls( &allocator, @@ -4668,13 +4803,9 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class_name.clone(), - compute_effective_start(class, &decorator_spans_to_remove, stmt_start), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4735,11 +4866,13 @@ pub fn transform_angular_file( // Generate .d.ts type declaration for this injectable let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); - result.dts_declarations.push(dts::generate_injectable_dts( - dts_core_namespace.as_str(), - &injectable_metadata, - type_argument_count, - )); + if in_dts { + result.dts_declarations.push(dts_entry(dts::generate_injectable_dts( + dts_core_namespace.as_str(), + &injectable_metadata, + type_argument_count, + ))); + } // Emit setClassMetadata for TestBed support. let decls_after_class = build_set_class_metadata_decls( @@ -4753,13 +4886,9 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class_name.clone(), - compute_effective_start(class, &decorator_spans_to_remove, stmt_start), - class.body.span.end, - )); + class_positions.push((class.span, class.body.span.end)); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4770,24 +4899,14 @@ pub fn transform_angular_file( // The analyzer counts an Angular constructor parameter decorator, and the Angular decorator of a `declare` // property, as removed on every class with an Angular class decorator. One the walk above left in place (a - // component whose template is missing, a constructor other than the implementation) keeps what it references. + // component whose template is missing, a constructor other than the implementation, a nested class it skipped) + // keeps what it references. { let stripped: rustc_hash::FxHashSet = decorator_spans_to_remove.iter().copied().collect(); let mut kept = DecoratorIdentifiers::default(); - for stmt in &parser_ret.program.body { - let class = match stmt { - Statement::ClassDeclaration(class) => class.as_ref(), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), - _ => continue, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => class.as_ref(), - _ => continue, - }, - _ => continue, - }; + for declared in &declared_classes { + let class = declared.class; for element in &class.body.body { let decorators: std::vec::Vec<_> = match element { oxc_ast::ast::ClassElement::MethodDefinition(method) @@ -4883,43 +5002,40 @@ pub fn transform_angular_file( if emit_ns_imports { additional_imports.push_str(&file_namespace_registry.generate_import_statements()); } - let ns_edit = if !additional_imports.is_empty() - && (!class_definitions.is_empty() || !jit_classes.is_empty()) - { - let ns_insert_pos = find_last_import_end(&parser_ret.program.body); - if let Some(insert_pos) = ns_insert_pos { - let bytes = source.as_bytes(); - let mut actual_pos = insert_pos; - // Skip past trailing whitespace/newline after the last import semicolon - while actual_pos < bytes.len() { - let c = bytes[actual_pos]; - if c == b'\n' { - actual_pos += 1; - break; - } else if c == b' ' || c == b'\t' || c == b'\r' { - actual_pos += 1; - } else { - break; - } + // Past the imports: where the added imports go, and the file-level + // declarations of every class. + let after_imports = find_last_import_end(&parser_ret.program.body).map_or(0, |insert_pos| { + let bytes = source.as_bytes(); + let mut actual_pos = insert_pos; + // Skip past trailing whitespace/newline after the last import semicolon + while actual_pos < bytes.len() { + let c = bytes[actual_pos]; + if c == b'\n' { + actual_pos += 1; + break; + } else if c == b' ' || c == b'\t' || c == b'\r' { + actual_pos += 1; + } else { + break; } - // Ensure the insert position doesn't fall inside any import elision - // edit span. Import elision edits extend past trailing newlines, which - // may cover the position we computed above. - // Note: elision edits are in ascending source order and non-overlapping - // (one per import declaration), so a single forward pass is sufficient — - // adjusting actual_pos forward can never land inside an earlier edit. - for edit in &elision_edits { - if (edit.start as usize) < actual_pos && (edit.end as usize) > actual_pos { - actual_pos = edit.end as usize; - } + } + // Ensure the insert position doesn't fall inside any import elision + // edit span. Import elision edits extend past trailing newlines, which + // may cover the position we computed above. + // Note: elision edits are in ascending source order and non-overlapping + // (one per import declaration), so a single forward pass is sufficient — + // adjusting actual_pos forward can never land inside an earlier edit. + for edit in &elision_edits { + if (edit.start as usize) < actual_pos && (edit.end as usize) > actual_pos { + actual_pos = edit.end as usize; } - Some(Edit::insert(actual_pos as u32, additional_imports).with_priority(10)) - } else { - Some(Edit::insert(0, additional_imports).with_priority(10)) } - } else { - None - }; + // A source is shorter than `u32::MAX` bytes, like its spans. + u32::try_from(actual_pos).unwrap_or(u32::MAX) + }); + let ns_edit = (!additional_imports.is_empty() + && (!class_definitions.is_empty() || !jit_classes.is_empty())) + .then(|| Edit::insert(after_imports, additional_imports).with_priority(10)); // 5c. Merge all edits let mut edits: Vec = elision_edits; @@ -4947,10 +5063,13 @@ pub fn transform_angular_file( edits.push(edit); } - // 5d. Class definition insertion edits - for (class_name, effective_start, class_body_end) in &class_positions { + // 5d. Class definition insertion edits. Every class's constants and child + // view functions go after the imports, joined into one edit in the order + // the classes compiled, as ngtsc declares its constant pool's statements. + let mut decls_after_imports = String::new(); + for (class_span, class_body_end) in &class_positions { if let Some((property_assignments, decls_before_class, decls_after_class)) = - class_definitions.get(class_name) + class_definitions.get(class_span) { // Static fields inside class body (before closing `}`) if !property_assignments.is_empty() { @@ -4960,17 +5079,49 @@ pub fn transform_angular_file( )); } - // Constants/child views before the class + // Constants/child views after the imports if !decls_before_class.is_empty() { - edits.push(Edit::insert(*effective_start, format!("{}\n", decls_before_class))); + decls_after_imports.push_str(decls_before_class); + decls_after_imports.push('\n'); } // Debug info/class metadata/HMR after the class - if !decls_after_class.is_empty() { + if !decls_after_class.is_empty() && !namespace_compiled.contains(class_span) { edits.push(Edit::insert(*class_body_end, format!("\n{}", decls_after_class))); } } } + if !decls_after_imports.is_empty() { + // ngtsc moves the imports above the rest of the file. Imports load + // before the module body runs anyway, so a statement ahead of the + // last import only needs the declarations before it. + // A decorator a class keeps comes before `export`, outside the + // statement's span. + let first_statement = parser_ret + .program + .body + .iter() + .find(|stmt| !matches!(stmt, Statement::ImportDeclaration(_))) + .map(|stmt| { + let class = match stmt { + Statement::ClassDeclaration(class) => Some(&**class), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => Some(&**class), + _ => None, + }, + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(&**class), + _ => None, + }, + _ => None, + }; + class + .and_then(|class| class.decorators.first()) + .map_or(stmt.span().start, |first| first.span.start.min(stmt.span().start)) + }); + let start = first_statement.map_or(after_imports, |start| start.min(after_imports)); + edits.push(Edit::insert(start, decls_after_imports)); + } // 5e. TDZ-safe hoisting of top-level bindings referenced by decorator // metadata but declared after the decorated class. Without this, the @@ -5226,22 +5377,13 @@ pub fn extract_component_resource_urls( let string_consts = with_imported_values(string_consts, analyzer.as_ref(), path, options); let mut urls = ComponentResourceUrls::default(); - for stmt in &program.body { - let class = match stmt { - Statement::ClassDeclaration(class) => class.as_ref(), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), - _ => continue, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => class.as_ref(), - _ => continue, - }, - _ => continue, - }; - let Some((template_url, style_urls)) = - super::decorator::extract_component_resource_urls_of(allocator, class, &string_consts) - else { + // Nested classes too: the transform compiles those. + for declared in crate::util::declared_classes(program) { + let Some((template_url, style_urls)) = super::decorator::extract_component_resource_urls_of( + allocator, + declared.class, + &string_consts, + ) else { continue; }; if let Some(template_url) = template_url { @@ -5402,20 +5544,17 @@ pub fn compile_component_hmr_update( let source_matches = input.source_fingerprint.is_none_or(|expected| expected == fingerprint); update.fingerprint = Some(fingerprint); - let Some(class) = program.body.iter().find_map(|stmt| { - let class = match stmt { - Statement::ClassDeclaration(class) => class.as_ref(), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), - _ => return None, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => class.as_ref(), - _ => return None, - }, - _ => return None, - }; - class.id.as_ref().is_some_and(|id| id.name == class_name).then_some(class) + // A nested class too: the transform compiles those, with an HMR + // initializer like ngtsc's, unless another class has its name or it is a + // `namespace` member. + let declared_classes = crate::util::declared_classes(program); + if ambiguous_class_names(&declared_classes).contains(class_name) { + return update; + } + let Some(class) = declared_classes.into_iter().find_map(|declared| { + let class = declared.class; + (declared.namespace.is_none() && class.id.as_ref().is_some_and(|id| id.name == class_name)) + .then_some(class) }) else { return update; }; @@ -5534,6 +5673,7 @@ fn compile_component_partial<'a>( metadata: &ComponentMetadata<'a>, view_queries: &[R3QueryMetadata<'a>], content_queries: &[R3QueryMetadata<'a>], + defer_block_dependencies: &[Option>], pool_starting_index: u32, namespace_registry: &mut NamespaceRegistry<'a>, ) -> FullCompilationResult<'a> { @@ -5542,6 +5682,7 @@ fn compile_component_partial<'a>( is_inline: metadata.template.is_some(), content_queries, view_queries, + defer_block_dependencies, }; let cmp_expr = crate::partial::compile_declare_component_from_metadata( allocator, @@ -5670,10 +5811,64 @@ fn html_parse_error_diagnostic( } } -/// The selector of the standalone component or directive an `imports` -/// entry refers to, by local name, and whether a named import brings it in, -/// which `@defer` may then replace with a dynamic `import()`. -type ImportSelector<'f> = &'f dyn Fn(&str) -> Option<(String, bool)>; +/// The standalone component or directive a class an `imports` entry names +/// refers to (see [`ImportedClass`]). +type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; + +/// What `@defer` reads of the standalone component or directive an `imports` +/// entry refers to. +struct ImportedClass { + class_use: super::defer_resolver::ClassUse, + /// Whether a named import brings it in, which `@defer` may then replace + /// with a dynamic `import()`. + is_imported: bool, + /// The file that declares the class (empty for this file) and its name + /// there: two references to one class are listed once. + declaration: (String, String), +} + +/// The module and the name of the export an imported `imports` entry reads: +/// `Cmp` of `import { Cmp }` and `ns.Cmp` of `import * as ns` read the same +/// one from the same module. +fn imported_export<'a>( + import_map: &ImportMap<'a>, + import: &ImportRef<'a>, +) -> Option<(Ident<'a>, Ident<'a>)> { + let info = import_map.get(&import.namespace.unwrap_or(import.name))?; + if info.is_type_only { + return None; + } + match import.namespace { + Some(_) if !info.is_named_import => Some((info.source_module, import.name)), + None if info.is_named_import => { + Some((info.source_module, info.imported_name.unwrap_or(import.name))) + } + _ => None, + } +} + +/// The [`ImportedClass`] a class of the file being compiled is. +fn local_class<'p>( + program: &'p oxc_ast::ast::Program<'p>, + consts: &crate::directive::StringConsts<'p>, + import: &ImportRef<'_>, + implicit_standalone: bool, +) -> Option { + if import.namespace.is_some() { + return None; + } + let class_use = super::defer_resolver::local_standalone_class_selector( + program, + consts, + &import.name, + implicit_standalone, + )?; + Some(ImportedClass { + class_use, + is_imported: false, + declaration: (String::new(), import.name.to_string()), + }) +} /// ngc's `DeferredSymbolTracker.canDefer` for the import declaration that /// binds a name, given the `imports` entries the component being compiled @@ -5684,7 +5879,7 @@ type CanDefer<'f> = &'f dyn Fn(&str, &[Ident<'_>]) -> bool; /// `resolveDeferBlocks` finds it. struct DeferCandidates<'a> { /// Each `imports` class with a known selector. - candidates: std::vec::Vec<(Ident<'a>, std::vec::Vec)>, + candidates: std::vec::Vec>, /// The candidates the template matches only inside `@defer` blocks. block_only: std::vec::Vec>, /// The `imports` entries naming a `block_only` candidate a named import @@ -5694,39 +5889,52 @@ struct DeferCandidates<'a> { } /// The [`DeferCandidates`] of a component, or `None` when its template has -/// no `@defer` block or an `imports` entry can't be read. +/// no `@defer` block or its `imports` names no class. Only an entry of an +/// `imports` array literal can be marked: one that doesn't name a class (a +/// spread, a nested array, `ns.Cmp`) is never marked, as ngc's +/// `registerDeferrableCandidate` skips anything but an identifier, and the +/// classes it brings in stay eager, like the classes of a `const` array +/// (`imports: IMPORTS`). fn implicit_defer_candidates<'a>( metadata: &ComponentMetadata<'a>, nodes: &[crate::ast::r3::R3Node<'a>], import_selector: ImportSelector<'_>, ) -> Option> { - let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports else { - return None; - }; - if raw_imports.entries.len() != metadata.import_entry_classes.len() - || !raw_imports - .entries - .iter() - .zip(&metadata.import_entry_classes) - .all(|(entry, class)| class.is_some() || matches!(entry, OutputExpression::ReadVar(_))) - || !super::defer_resolver::template_has_defer_block(nodes) + if let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports + && raw_imports.entries.len() != metadata.import_entry_classes.len() { return None; } + if metadata.import_refs.is_empty() || !super::defer_resolver::template_has_defer_block(nodes) { + return None; + } let mut imported = std::vec::Vec::new(); - let candidates: std::vec::Vec<_> = metadata - .import_entry_classes - .iter() - .flatten() - .map(|(name, _)| *name) - .filter_map(|name| { - let (selector, is_imported) = import_selector(name.as_str())?; - if is_imported { - imported.push(name); + // Each class is listed once, where `imports` first names it, as ngc's + // scope does; an identifier naming it may still lazy-load it. + let mut candidates: std::vec::Vec> = std::vec::Vec::new(); + let mut declarations = std::vec::Vec::new(); + for import in &metadata.import_refs { + let Some(class) = import_selector(import) else { continue }; + if class.is_imported { + imported.push(import.key); + } + if let Some(index) = + declarations.iter().position(|declaration| *declaration == class.declaration) + { + if class.is_imported && !imported.contains(&candidates[index].0) { + candidates[index].0 = import.key; } - Some((name, crate::pipeline::selector::CssSelector::parse(&selector))) - }) - .collect(); + } else { + declarations.push(class.declaration); + candidates + .push((import.key, super::defer_resolver::CandidateUse::of(&class.class_use))); + } + } + // ngc's declarations list the pipes after the directives + // (`componentDependenciesToDeclarations`). + candidates.sort_by_key(|(_, class_use)| { + matches!(class_use, super::defer_resolver::CandidateUse::Pipe(_)) + }); let block_only = super::defer_resolver::implicitly_deferred_imports(nodes, &candidates); let marked = metadata .import_entry_classes @@ -5738,21 +5946,24 @@ fn implicit_defer_candidates<'a>( Some(DeferCandidates { candidates, block_only, marked }) } -/// The class a top-level statement declares, exported or not. -#[cfg(feature = "cross_file_elision")] -fn statement_class<'a>(stmt: &'a Statement<'a>) -> Option<&'a oxc_ast::ast::Class<'a>> { - match stmt { - Statement::ClassDeclaration(class) => Some(class), - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class), - _ => None, - }, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::ClassDeclaration(class) => Some(class), - _ => None, - }, - _ => None, +/// The class names two or more classes of the file declare (in different +/// functions or blocks). A `namespace` member never gets HMR code, so it +/// does not count. +pub(super) fn ambiguous_class_names<'a>( + declared_classes: &[crate::util::DeclaredClass<'_, 'a>], +) -> rustc_hash::FxHashSet<&'a str> { + let mut seen = rustc_hash::FxHashSet::default(); + let mut ambiguous = rustc_hash::FxHashSet::default(); + for name in declared_classes + .iter() + .filter(|declared| declared.namespace.is_none()) + .filter_map(|declared| declared.class.id.as_ref()) + { + if !seen.insert(name.name.as_str()) { + ambiguous.insert(name.name.as_str()); + } } + ambiguous } /// The `imports` entries another component of the file marks as deferrable @@ -5828,6 +6039,98 @@ fn component_deferrable_marks<'a>( .map_or_else(std::vec::Vec::new, |candidates| candidates.marked) } +/// What each `@defer` block uses, with the block's span. +type DeferBlockDependencies<'a> = std::vec::Vec<(Span, std::vec::Vec>)>; + +/// Moves the `imports` entries a component's template matches only inside +/// `@defer` blocks out of its eager dependencies, as ngc does in both its full +/// and partial compilations (#313). Returns what each block uses, keyed by the +/// block's span, when the blocks get their own dependency functions, and the +/// names now lazy-loaded, which join `metadata.deferred_imports`. Needs +/// cross-file analysis for the selectors; skipped under HMR, like ngc. +fn resolve_implicit_defer<'a>( + metadata: &mut ComponentMetadata<'a>, + nodes: &[crate::ast::r3::R3Node<'a>], + options: &TransformOptions, + import_selector: Option>, + can_defer: Option>, +) -> (Option>, Vec) { + let mut implicitly_deferred_imports = Vec::new(); + // 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(can_defer) = can_defer + && !options.hmr + && let Some(DeferCandidates { candidates, block_only, marked }) = + implicit_defer_candidates(metadata, nodes, import_selector) + { + // ngc's `DeferredSymbolTracker.canDefer`, over the whole file: the + // `imports` entries this component marks are handed over with the + // name, and the ones other components mark are counted there. A + // class two entries name is listed once (`seenDeps` in + // `resolveAllDeferredDependencies`). + let mut deferred = std::vec::Vec::new(); + for &name in &block_only { + if !deferred.contains(&name) && marked.contains(&name) && can_defer(&name, &marked) { + deferred.push(name); + } + } + // 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(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())); + metadata.import_refs.retain(|import| !is_removed(import.key.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_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); + } + } + (defer_block_dependencies, implicitly_deferred_imports) +} + fn compile_component_full<'a>( allocator: &'a Allocator, template: &'a str, @@ -5915,18 +6218,69 @@ fn compile_component_full<'a>( // Partial mode stops here. Its declaration carries the template as a // verbatim string for the linker to compile at consumer build time, but // ngtsc still parses it first: its template errors are reported and - // `compilePartial` emits nothing for the class (handler.ts). + // `compilePartial` emits nothing for the class (handler.ts). Its `@defer` + // blocks are resolved as in a full compilation, and the declaration + // carries their dependency functions (`deferBlockDependencies`). if matches!(options.compilation_mode, crate::CompilationMode::Partial) { - return Ok(compile_component_partial( + // Read before `resolve_implicit_defer` leaves out what `@defer` provides. + let import_refs: std::vec::Vec<_> = metadata.import_refs.iter().copied().collect(); + let (defer_block_dependencies, implicitly_deferred_imports) = + resolve_implicit_defer(metadata, &r3_result.nodes, options, import_selector, can_defer); + // `dependencies` lists each class once, like ngc's scope, which + // `[Cmp, ns.Cmp]` would otherwise give twice, and leaves out a class + // `@defer` lazy-loads however else `imports` names it. Two names are + // one class when they read one export of one module, or when + // cross-file analysis finds one declaration. + if let Some(import_selector) = import_selector + && metadata.import_refs.iter().any(|import| import.namespace.is_some()) + { + let class_of = |import: &ImportRef<'a>| { + ( + import_selector(import).map(|class| class.declaration), + imported_export(import_map, import), + ) + }; + let mut seen: std::vec::Vec<_> = import_refs + .iter() + .filter(|import| !metadata.import_refs.iter().any(|kept| kept.key == import.key)) + .map(class_of) + .collect(); + metadata.import_refs.retain(|import| { + let (declaration, export) = class_of(import); + let is_seen = seen.iter().any(|(seen_declaration, seen_export)| { + (declaration.is_some() && *seen_declaration == declaration) + || (export.is_some() && *seen_export == export) + }); + seen.push((declaration, export)); + !is_seen + }); + } + let defer_block_dependencies: std::vec::Vec<_> = defer_block_dependencies + .unwrap_or_default() + .into_iter() + .map(|(_, dependencies)| { + super::defer_resolver::compile_defer_block_resolver( + allocator, + &dependencies, + &metadata.deferred_imports, + &import_refs, + import_map, + ) + }) + .collect(); + let mut result = compile_component_partial( allocator, namespace_registry.angular_core_ns().as_str(), template, metadata, &view_queries, &content_queries, + &defer_block_dependencies, pool_starting_index, namespace_registry, - )); + ); + result.implicitly_deferred_imports = implicitly_deferred_imports; + return Ok(result); } // Capture ng-content selectors from the R3 AST for .d.ts generation @@ -6013,80 +6367,10 @@ fn compile_component_full<'a>( } // `imports` entries matched only inside `@defer` are lazy-loaded, as in - // ngc's full compilation (#313). Needs cross-file analysis for the - // selectors; skipped under HMR, like ngc. - let mut implicitly_deferred_imports = Vec::new(); - // 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(can_defer) = can_defer - && !options.hmr - && let Some(DeferCandidates { candidates, block_only, marked }) = - implicit_defer_candidates(metadata, &r3_result.nodes, import_selector) - { - // ngc's `DeferredSymbolTracker.canDefer`, over the whole file: the - // `imports` entries this component marks are handed over with the - // name, and the ones other components mark are counted there. A - // class two entries name is listed once (`seenDeps` in - // `resolveAllDeferredDependencies`). - let mut deferred = std::vec::Vec::new(); - for &name in &block_only { - if !deferred.contains(&name) && marked.contains(&name) && can_defer(&name, &marked) { - deferred.push(name); - } - } - // 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_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); - } - } + // ngc's full compilation (#313). + let import_refs: std::vec::Vec<_> = metadata.import_refs.iter().copied().collect(); + let (defer_block_dependencies, implicitly_deferred_imports) = + resolve_implicit_defer(metadata, &r3_result.nodes, options, import_selector, can_defer); // Build the deferrable-dependencies resolver from // `@Component.deferredImports`. @@ -6174,7 +6458,7 @@ fn compile_component_full<'a>( allocator, &dependencies, &metadata.deferred_imports, - &metadata.import_entry_classes, + &import_refs, import_map, ); (span, deps_fn) @@ -6428,7 +6712,7 @@ fn compile_component_full<'a>( // The 1-indexed line of the `class` keyword (ngtsc uses `clazz.name.pos`, // which sits right after it), with TypeScript's line-break rules. - let class_line_number = line_number_at(source, metadata.class_span.start as usize); + let class_line_number = line_number_at(source, metadata.class_name_pos as usize); // Build the debug info with the actual class declaration line number let debug_info = R3ClassDebugInfo::new(component_type, metadata.class_name.clone()) @@ -6553,17 +6837,18 @@ fn cmp_definition_has_inline_long_style(cmp_definition: &OutputExpression<'_>) - } /// Points the earlier component's inline first use of a long style that a -/// later component shared at the shared constant, and declares it before that -/// component, like the `FixupExpression` ngtsc's single file pool resolves. +/// later component shared at the shared constant, like the `FixupExpression` +/// ngtsc's single file pool resolves. The constant is declared with the +/// current component's declarations, where ngtsc pools it: all of them go +/// after the imports, before the earlier component. /// /// `inline_style_cmps` holds the components whose ɵcmp has a long style inline, -/// with the ɵcmp text in their `class_definitions` entry. Without one (that -/// component failed to compile), the constant goes before the current class. +/// with the ɵcmp text in their `class_definitions` entry. fn apply_inherited_fixups<'a>( allocator: &'a Allocator, fixups: Vec>, - inline_style_cmps: &mut [(String, OutputExpression<'a>, String)], - class_definitions: &mut HashMap, + inline_style_cmps: &mut [(Span, OutputExpression<'a>, String)], + class_definitions: &mut HashMap, current_declarations_js: &mut String, ) { let emitter = JsEmitter::new(); @@ -6579,7 +6864,7 @@ fn apply_inherited_fixups<'a>( }, &allocator, ))); - let first_use = inline_style_cmps.iter_mut().find_map(|(class_name, cmp, cmp_js)| { + let first_use = inline_style_cmps.iter_mut().find_map(|(class_span, cmp, cmp_js)| { let Some(OutputExpression::LiteralArray(styles)) = cmp_definition_field_mut(cmp, "styles") else { @@ -6591,20 +6876,17 @@ fn apply_inherited_fixups<'a>( ReadVarExpr { name: fixup.name, source_span: None }, &allocator, )); - Some((class_name, emitter.emit_expression(cmp), cmp_js)) + Some((class_span, emitter.emit_expression(cmp), cmp_js)) }); - let first_use = first_use.and_then(|(class_name, new_cmp_js, cmp_js)| { - Some((class_definitions.get_mut(class_name.as_str())?, new_cmp_js, cmp_js)) + let first_use = first_use.and_then(|(class_span, new_cmp_js, cmp_js)| { + Some((class_definitions.get_mut(class_span)?, new_cmp_js, cmp_js)) }); - let Some(((property_assignments, decls_before_class, _), new_cmp_js, cmp_js)) = first_use - else { - current_declarations_js.push_str(&declaration); - current_declarations_js.push('\n'); + current_declarations_js.push_str(&declaration); + current_declarations_js.push('\n'); + let Some(((property_assignments, ..), new_cmp_js, cmp_js)) = first_use else { continue; }; *property_assignments = property_assignments.replacen(cmp_js.as_str(), &new_cmp_js, 1); - decls_before_class.push_str(&declaration); - decls_before_class.push('\n'); *cmp_js = new_cmp_js; } } @@ -6721,10 +7003,11 @@ pub fn compile_component_template<'a>( } // Stage 1.5: Remove whitespace if not preserving (default behavior) + let untrimmed: &'a [crate::ast::html::HtmlNode<'a>] = allocator.alloc(html_result.nodes); let nodes = if parse_options.preserve_whitespaces { - &html_result.nodes + untrimmed } else { - let processed = remove_whitespaces(allocator, &html_result.nodes, true); + let processed = remove_whitespaces(allocator, untrimmed, true); allocator.alloc(processed) as &_ }; @@ -6734,7 +7017,8 @@ pub fn compile_component_template<'a>( angular_version: None, ..R3TransformOptions::default() }; - let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); + let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options) + .with_untrimmed_nodes(untrimmed); let r3_result = transformer.transform(nodes); if !r3_result.errors.is_empty() { @@ -6889,6 +7173,29 @@ pub fn compile_template_to_js_with_options<'a>( ingest_options, ); + // Compile the host bindings before the template, as + // `compile_component_full` does (Angular's `compileComponentFromMetadata` + // runs `baseDirectiveFields` first, with one pool): their constants come + // first, and the template reuses equal ones. + let mut component_pool = job.pool.take_shared(); + let host_output = options.host.as_ref().and_then(|host_input| { + compile_host_bindings_from_input( + allocator, + crate::r3::CORE_ALIAS, + host_input, + component_name, + options.selector.as_deref(), + job.pool.next_name_index(), + &mut component_pool, + options.angular_version, + options.legacy_optional_chaining, + ) + }); + job.pool.inherit_shared(component_pool); + if let Some((_, next_pool_index)) = &host_output { + job.pool.continue_from(*next_pool_index); + } + let compiled = compile_template(&mut job); // Collect any diagnostics from the compilation job @@ -6906,6 +7213,14 @@ pub fn compile_template_to_js_with_options<'a>( // 2. Main template function as a declaration let mut all_statements: OxcVec<'a, OutputStatement<'a>> = OxcVec::new_in(&allocator); + // The host bindings' pool declarations (pure functions, etc.) come first, + // as they were pooled first. + let mut host_fn = None; + if let Some((host_result, _)) = host_output { + all_statements.extend(host_result.declarations); + host_fn = host_result.host_binding_fn; + } + // Add all declarations first (child view functions come before main template) for decl in compiled.declarations { all_statements.push(decl); @@ -6927,43 +7242,21 @@ pub fn compile_template_to_js_with_options<'a>( all_statements.push(main_fn_stmt); } - // Stage 6: Compile host bindings if provided via options - let host_pool_starting_index = job.pool.next_name_index(); - if let Some(ref host_input) = options.host { - if let Some((host_result, _)) = compile_host_bindings_from_input( + // Add the host bindings function as a declaration if present + if let Some(host_fn) = host_fn + && let Some(fn_name) = host_fn.name + { + let host_fn_stmt = OutputStatement::DeclareFunction(oxc_allocator::Box::new_in( + DeclareFunctionStmt { + name: fn_name, + params: host_fn.params, + statements: host_fn.statements, + modifiers: StmtModifier::NONE, + source_span: host_fn.source_span, + }, &allocator, - crate::r3::CORE_ALIAS, - host_input, - component_name, - options.selector.as_deref(), - host_pool_starting_index, - &mut job.pool.take_shared(), - options.angular_version, - options.legacy_optional_chaining, - ) { - // Add host binding pool declarations (pure functions, etc.) - for decl in host_result.declarations { - all_statements.push(decl); - } - - // Add the host bindings function as a declaration if present - if let Some(host_fn) = host_result.host_binding_fn { - if let Some(fn_name) = host_fn.name.clone() { - let host_fn_stmt = - OutputStatement::DeclareFunction(oxc_allocator::Box::new_in( - DeclareFunctionStmt { - name: fn_name, - params: host_fn.params, - statements: host_fn.statements, - modifiers: StmtModifier::NONE, - source_span: host_fn.source_span, - }, - &allocator, - )); - all_statements.push(host_fn_stmt); - } - } - } + )); + all_statements.push(host_fn_stmt); } // Generate code with optional source map @@ -7646,6 +7939,10 @@ fn compile_host_bindings_from_input<'a>( ); job.core_namespace = Ident::from(core_namespace); job.pool.inherit_shared(std::mem::take(shared_pool)); + // Unique in the pool, like the template functions: the linker shares it + // across the declarations of a scope (Angular's `addNamesToView`) + let fn_name = format!("{component_name}_{}", job.fn_suffix); + job.root.fn_name = Some(job.pool.unique_name(&fn_name, false)); let result = compile_host_bindings(&mut job); *shared_pool = job.pool.take_shared(); @@ -7723,12 +8020,16 @@ pub fn compile_host_bindings_for_linker<'a>( /// from `ɵɵngDeclareComponent` partial declarations. #[derive(Debug)] pub struct LinkerTemplateOutput { - /// All declarations (child view functions, pooled constants, main template function) - /// as JavaScript code. These need to be emitted before the `defineComponent` call. + /// The declarations (child view functions, pooled constants) as JavaScript code. + /// These need to be emitted before the `defineComponent` call. pub declarations_js: String, - /// The name of the main template function (e.g., "ComponentName_Template"). - pub template_fn_name: String, + /// The main template function as a JavaScript expression + /// (`function ComponentName_Template(rf, ctx) { ... }`). + pub template_fn_js: String, + + /// The next available pool index after template compilation. + pub next_pool_index: u32, /// Number of element/text/container declarations in the root view. pub decls: u32, @@ -7751,7 +8052,12 @@ pub struct LinkerTemplateOutput { /// /// Angular's `compileComponentFromMetadata` compiles the host bindings first, so the /// template's pool continues from `pool_starting_index` and reuses what the host bindings -/// pooled, from `shared_pool` (see [`compile_host_bindings_for_linker`]). +/// (and the earlier declarations of the scope) pooled, from `shared_pool` (see +/// [`compile_host_bindings_for_linker`]), which gets what the template pools. +/// +/// `has_directive_dependencies` is Angular's linker's `!isStandalone` or a directive, +/// component or NgModule dependency. A `@defer` block with a dependency function counts too, +/// and without any the template is compiled in DomOnly mode. pub fn compile_template_for_linker<'a>( allocator: &'a Allocator, template: &'a str, @@ -7761,8 +8067,9 @@ pub fn compile_template_for_linker<'a>( core_namespace: &str, angular_version: Option, defer_block_deps: &[Option<&str>], + has_directive_dependencies: bool, pool_starting_index: u32, - shared_pool: SharedPool<'a>, + shared_pool: &mut SharedPool<'a>, ) -> Result> { use crate::pipeline::ingest::{IngestOptions, ingest_component_with_options}; use oxc_allocator::FromIn; @@ -7827,13 +8134,23 @@ pub fn compile_template_for_linker<'a>( }); (span, deps_fn) }) - .collect(); + .collect::>(); + let mode = if has_directive_dependencies + || defer_blocks.values().any(Option::is_some) + || angular_version.is_some_and(|v| !v.supports_dom_only_templates()) + { + TemplateCompilationMode::Full + } else { + TemplateCompilationMode::DomOnly + }; // Stage 3-5: Ingest and compile let ingest_options = IngestOptions { - mode: TemplateCompilationMode::Full, - relative_context_file_path: None, - i18n_use_external_ids: true, + mode, + // Angular's linker names Closure messages after the file it links + // (`MSG__X_MAIN_JS_0` for `/x/main.js`), never after their ids. + relative_context_file_path: Some(Ident::from_in(file_path, allocator)), + i18n_use_external_ids: false, defer_block_deps_emit_mode: DeferBlockDepsEmitMode::PerBlock, relative_template_path: None, enable_debug_locations: false, @@ -7856,9 +8173,12 @@ pub fn compile_template_for_linker<'a>( ingest_options, ); job.core_namespace = Ident::from_in(core_namespace, &allocator); - job.pool.inherit_shared(shared_pool); + // The caller drops what a failed template pooled. + job.pool.inherit_shared(std::mem::take(shared_pool)); let compiled = compile_template(&mut job); + *shared_pool = job.pool.take_shared(); + let next_pool_index = job.pool.next_name_index(); // Collect diagnostics diagnostics.extend(job.diagnostics.into_iter()); @@ -7928,40 +8248,16 @@ pub fn compile_template_for_linker<'a>( let ng_content_selectors_js = job.content_selectors.take().map(|expr| emitter.emit_expression(&expr)); - // Get template function name - let template_fn_name = compiled - .template_fn - .name - .as_ref() - .map(|n| n.to_string()) - .unwrap_or_else(|| format!("{component_name}_Template")); - - // Emit all declarations + main template function as JS code - let mut all_statements: OxcVec<'a, OutputStatement<'a>> = OxcVec::new_in(&allocator); - - for decl in compiled.declarations { - all_statements.push(decl); - } - - if let Some(fn_name) = compiled.template_fn.name.clone() { - let main_fn_stmt = OutputStatement::DeclareFunction(oxc_allocator::Box::new_in( - DeclareFunctionStmt { - name: fn_name, - params: compiled.template_fn.params, - statements: compiled.template_fn.statements, - modifiers: StmtModifier::NONE, - source_span: compiled.template_fn.source_span, - }, - &allocator, - )); - all_statements.push(main_fn_stmt); - } - - let declarations_js = emitter.emit_statements(&all_statements); + // The main template function goes inline in the definition, as in Angular's output + let template_fn_js = emitter.emit_expression(&OutputExpression::Function( + oxc_allocator::Box::new_in(compiled.template_fn, &allocator), + )); + let declarations_js = emitter.emit_statements(&compiled.declarations); Ok(LinkerTemplateOutput { declarations_js, - template_fn_name, + template_fn_js, + next_pool_index, decls, vars, consts_js, diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index f62e44b67..f929888be 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -62,4 +62,5 @@ pub use property_decorators::{ extract_input_metadata, extract_input_metadata_in, extract_output_metadata, extract_output_metadata_in, extract_view_queries, find_angular_class_decorator, }; +pub(crate) use query::split_query_predicate; pub use query::{create_content_queries_function, create_view_queries_function}; diff --git a/crates/oxc_angular_compiler/src/directive/query.rs b/crates/oxc_angular_compiler/src/directive/query.rs index 559400445..b17361155 100644 --- a/crates/oxc_angular_compiler/src/directive/query.rs +++ b/crates/oxc_angular_compiler/src/directive/query.rs @@ -232,18 +232,14 @@ fn get_query_predicate<'a>( let mut entries = Vec::new_in(&allocator); for selector in selectors.iter() { - // Split by comma and trim - for part in selector.as_str().split(',') { - let trimmed = part.trim(); - if !trimmed.is_empty() { - entries.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { - value: LiteralValue::String(Ident::from(trimmed)), - source_span: None, - }, - &allocator, - ))); - } + for part in split_query_predicate(selector.as_str()) { + entries.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { + value: LiteralValue::String(Ident::from(part)), + source_span: None, + }, + &allocator, + ))); } } @@ -264,6 +260,19 @@ fn get_query_predicate<'a>( } } +/// The refs of one string of a query predicate: split by comma and trimmed, +/// keeping empty parts (`'x, '` gives `["x", ""]`), as Angular's +/// `getQueryPredicate` does with `selector.split(',').map(t => t.trim())`. +pub fn split_query_predicate(selector: &str) -> impl Iterator { + selector.split(',').map(js_trim) +} + +/// `String.prototype.trim`: Unicode white space and line terminators, plus +/// U+FEFF but not U+0085. +fn js_trim(value: &str) -> &str { + value.trim_matches(|c: char| (c.is_whitespace() && c != '\u{85}') || c == '\u{FEFF}') +} + // Note: get_query_create_parameters has been replaced by: // - get_query_create_parameters_with_predicate (for view queries with pre-pooled predicates) // - get_content_query_create_parameters_with_predicate (for content queries with pre-pooled predicates) diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index e05462aed..de1a81c7a 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -33,7 +33,7 @@ use std::collections::HashMap; /// that should be injected into the corresponding `.d.ts` class. #[derive(Debug, Clone, Default)] pub struct DtsDeclaration { - /// The name of the class. + /// The name of the class; dotted (`NS.App`) for a `namespace` member. pub class_name: String, /// The static member declarations to add to the class body in `.d.ts`. /// This is a newline-separated string of `static` property declarations. diff --git a/crates/oxc_angular_compiler/src/dts_inject.rs b/crates/oxc_angular_compiler/src/dts_inject.rs index 46f2c349a..25b3fccaa 100644 --- a/crates/oxc_angular_compiler/src/dts_inject.rs +++ b/crates/oxc_angular_compiler/src/dts_inject.rs @@ -40,7 +40,8 @@ use crate::r3::{CORE, CORE_ALIAS}; /// One class's `.d.ts` static member declarations. pub struct DtsInjectDeclaration { - /// The class the members belong to. + /// The class the members belong to; dotted (`NS.App`) for a class of a + /// `namespace`. pub class_name: String, /// Newline-separated `static …;` member declarations. /// Namespace-qualified type names (`i0.ɵɵComponentDeclaration`, @@ -154,10 +155,17 @@ fn collect_identifiers(program: &Program<'_>) -> FxHashSet { /// The `ClassBody` of the class named `class_name`, searching through /// export wrappers and ambient module blocks (the latter covers /// `declare module` declarations a bundler may wrap library classes in). +/// A dotted name (`NS.App`) is the class `App` of namespace `NS`. A class +/// declared in `statements` comes before one of the same name in a block. fn find_class_body<'a, 'b>( statements: &'b [Statement<'a>], class_name: &str, ) -> Option<&'b ClassBody<'a>> { + if class_name.contains('.') { + let path: std::vec::Vec<&str> = class_name.split('.').collect(); + return find_qualified_class_body(statements, &path); + } + fn from_namespace<'a, 'b>( ns: &'b oxc_ast::ast::TSNamespaceDeclaration<'a>, class_name: &str, @@ -175,15 +183,17 @@ fn find_class_body<'a, 'b>( fn from_declaration<'a, 'b>( decl: &'b Declaration<'a>, class_name: &str, + in_block: bool, ) -> Option<&'b ClassBody<'a>> { match decl { Declaration::ClassDeclaration(class) => { - if class.id.as_ref().map(|id| id.name.as_str()) == Some(class_name) { + if !in_block && class.id.as_ref().map(|id| id.name.as_str()) == Some(class_name) { Some(&*class.body) } else { None } } + _ if !in_block => None, // `declare module "pkg" { export class … }` Declaration::TSExternalModuleDeclaration(module) => { module.body.as_deref().and_then(|block| find_class_body(&block.body, class_name)) @@ -196,31 +206,78 @@ fn find_class_body<'a, 'b>( } } - for stmt in statements { - let found = match stmt { - // `export declare class` — oxc splits export-with-declaration - // into `ExportDeclaration`, while `export { a }` is - // `ExportNamedDeclaration` (which never wraps a declaration). - Statement::ExportDeclaration(export) => { - from_declaration(&export.declaration, class_name) - } - Statement::ExportDefaultDeclaration(export) => match &export.declaration { - oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class) - if class.id.as_ref().map(|id| id.name.as_str()) == Some(class_name) => - { - Some(&*class.body) + for in_block in [false, true] { + for stmt in statements { + let found = match stmt { + // `export declare class` — oxc splits export-with-declaration + // into `ExportDeclaration`, while `export { a }` is + // `ExportNamedDeclaration` (which never wraps a declaration). + Statement::ExportDeclaration(export) => { + from_declaration(&export.declaration, class_name, in_block) } - _ => None, - }, - other => other.as_declaration().and_then(|decl| from_declaration(decl, class_name)), - }; - if found.is_some() { - return found; + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class) + if !in_block + && class.id.as_ref().map(|id| id.name.as_str()) == Some(class_name) => + { + Some(&*class.body) + } + _ => None, + }, + other => other + .as_declaration() + .and_then(|decl| from_declaration(decl, class_name, in_block)), + }; + if found.is_some() { + return found; + } } } None } +/// The class at `path` (`["A", "B", "C"]` for `A.B.C`): every segment but the +/// last names a namespace, in `statements` itself, and the last a class +/// declared directly in the innermost one. Namespaces of one name merge, so +/// each is tried in turn. +fn find_qualified_class_body<'a, 'b>( + statements: &'b [Statement<'a>], + path: &[&str], +) -> Option<&'b ClassBody<'a>> { + fn in_namespace<'a, 'b>( + ns: &'b oxc_ast::ast::TSNamespaceDeclaration<'a>, + path: &[&str], + ) -> Option<&'b ClassBody<'a>> { + let (first, rest) = path.split_first()?; + if ns.id.name.as_str() != *first || rest.is_empty() { + return None; + } + match &ns.body { + TSNamespaceDeclarationBody::TSModuleBlock(block) => { + find_qualified_class_body(&block.body, rest) + } + TSNamespaceDeclarationBody::TSNamespaceDeclaration(inner) => in_namespace(inner, rest), + } + } + + statements.iter().find_map(|stmt| { + let decl = match stmt { + Statement::ExportDeclaration(export) => Some(&export.declaration), + other => other.as_declaration(), + }?; + match decl { + Declaration::ClassDeclaration(class) + if path.len() == 1 + && class.id.as_ref().map(|id| id.name.as_str()) == Some(path[0]) => + { + Some(&*class.body) + } + Declaration::TSNamespaceDeclaration(ns) => in_namespace(ns, path), + _ => None, + } + }) +} + /// The member's name as written, if it is a non-computed static or string /// literal key. fn property_key_name(key: &PropertyKey<'_>) -> Option { diff --git a/crates/oxc_angular_compiler/src/factory/compiler.rs b/crates/oxc_angular_compiler/src/factory/compiler.rs index a84cfce0a..d000836e6 100644 --- a/crates/oxc_angular_compiler/src/factory/compiler.rs +++ b/crates/oxc_angular_compiler/src/factory/compiler.rs @@ -42,7 +42,7 @@ use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, BinaryOperator, BinaryOperatorExpr, DeclareVarStmt, ExpressionStatement, FnParam, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, LiteralExpr, LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, - ReturnStatement, StmtModifier, + ReturnStatement, StmtModifier, clone_callee, }; use crate::r3::Identifiers; @@ -210,7 +210,7 @@ pub fn compile_factory_function<'a>( OutputExpression::InvokeFunction(Box::new_in( InvokeFunctionExpr { fn_expr: Box::new_in( - delegated_meta.delegate.clone_in(allocator), + clone_callee(&delegated_meta.delegate, allocator), &allocator, ), args: delegate_args, diff --git a/crates/oxc_angular_compiler/src/i18n/mod.rs b/crates/oxc_angular_compiler/src/i18n/mod.rs index 8f1eff85c..10531f861 100644 --- a/crates/oxc_angular_compiler/src/i18n/mod.rs +++ b/crates/oxc_angular_compiler/src/i18n/mod.rs @@ -40,7 +40,9 @@ pub use ast::{ BlockPlaceholder, Container, Icu, IcuPlaceholder, Message, MessagePlaceholder, MessageSpan, Node, Placeholder, TagPlaceholder, Text, Visitor, }; -pub use digest::{compute_decimal_digest, compute_digest, compute_msg_id, fingerprint, sha1}; +pub use digest::{ + compute_decimal_digest, compute_digest, compute_msg_id, decimal_digest, fingerprint, sha1, +}; pub use extractor_merger::{ ExtractionResult, MergeResult, TranslatedNode, extract_messages, merge_translations, }; diff --git a/crates/oxc_angular_compiler/src/injectable/compiler.rs b/crates/oxc_angular_compiler/src/injectable/compiler.rs index e93373e14..216ebcb37 100644 --- a/crates/oxc_angular_compiler/src/injectable/compiler.rs +++ b/crates/oxc_angular_compiler/src/injectable/compiler.rs @@ -23,7 +23,7 @@ use crate::factory::{ use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, FnParam, InvokeFunctionExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, - ReadVarExpr, + ReadVarExpr, clone_callee, }; use crate::r3::Identifiers; @@ -99,7 +99,8 @@ fn build_factory_expression<'a>( InjectableProvider::UseClass { class_expr, deps, .. } => { match deps { - Some(deps) if !deps.is_empty() => { + // Even an empty `deps: []` builds `new useClass()`, like ngtsc. + Some(deps) => { // useClass with deps: use delegated factory with conditional pattern // This generates the proper __ngConditionalFactory__ pattern. // See: injectable_compiler_2.ts:69-75 @@ -133,7 +134,8 @@ fn build_factory_expression<'a>( InjectableProvider::UseFactory { factory, deps } => { match deps { - Some(deps) if !deps.is_empty() => { + // Even an empty `deps: []` builds the delegated factory, like ngtsc. + Some(deps) => { // useFactory with deps: use delegated factory with conditional pattern // This generates the proper __ngConditionalFactory__ pattern. // See: injectable_compiler_2.ts:88-95 @@ -286,14 +288,15 @@ fn create_inject_call<'a>( )) } -/// Creates a factory delegation: `MyClass.ɵfac` +/// Creates a factory delegation: `MyClass.ɵfac`, or `(Maybe?.cls).ɵfac` for an +/// optional chain. fn create_factory_delegation<'a>( allocator: &'a Allocator, type_expr: &OutputExpression<'a>, ) -> OutputExpression<'a> { OutputExpression::ReadProp(Box::new_in( ReadPropExpr { - receiver: Box::new_in(type_expr.clone_in(allocator), &allocator), + receiver: Box::new_in(clone_callee(type_expr, allocator), &allocator), name: Ident::from("ɵfac"), optional: false, source_span: None, @@ -350,7 +353,7 @@ fn create_use_factory_wrapper<'a>( // factory() let factory_call = OutputExpression::InvokeFunction(Box::new_in( InvokeFunctionExpr { - fn_expr: Box::new_in(factory.clone_in(allocator), &allocator), + fn_expr: Box::new_in(clone_callee(factory, allocator), &allocator), args: Vec::new_in(&allocator), pure: false, optional: false, diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index eed37edc5..60867a9a4 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -66,8 +66,9 @@ pub struct UseClassMetadata<'a> { pub class_expr: OutputExpression<'a>, /// Whether this is wrapped in forwardRef(). pub is_forward_ref: bool, - /// Dependencies for the class constructor. - pub deps: Vec<'a, DependencyMetadata<'a>>, + /// Dependencies for the class constructor, `None` without a `deps` key. + /// An empty `deps: []` still makes ngtsc build `new useClass()`. + pub deps: Option>>, } /// Metadata for useFactory provider. @@ -75,8 +76,9 @@ pub struct UseClassMetadata<'a> { pub struct UseFactoryMetadata<'a> { /// The factory function expression. pub factory: OutputExpression<'a>, - /// Dependencies for the factory function. - pub deps: Vec<'a, DependencyMetadata<'a>>, + /// Dependencies for the factory function, `None` without a `deps` key. + /// An empty `deps: []` still makes ngtsc build the delegated factory. + pub deps: Option>>, } /// Metadata for useExisting provider. @@ -139,22 +141,14 @@ impl<'a> InjectableMetadata<'a> { use_existing.is_forward_ref, ); } else if let Some(use_class) = &self.use_class { - let deps = if use_class.deps.is_empty() { - None - } else { - Some(convert_deps_to_r3(allocator, &use_class.deps)) - }; + let deps = use_class.deps.as_ref().map(|deps| convert_deps_to_r3(allocator, deps)); builder = builder.use_class( use_class.class_expr.clone_in(allocator), use_class.is_forward_ref, deps, ); } else if let Some(use_factory) = &self.use_factory { - let deps = if use_factory.deps.is_empty() { - None - } else { - Some(convert_deps_to_r3(allocator, &use_factory.deps)) - }; + let deps = use_factory.deps.as_ref().map(|deps| convert_deps_to_r3(allocator, deps)); builder = builder.use_factory(use_factory.factory.clone_in(allocator), deps); } @@ -489,36 +483,35 @@ fn extract_forward_ref_or_expression<'a>( convert_oxc_expression(allocator, expr, source_text).map(|e| (e, false)) } +/// The `deps` array of a provider config, `None` without one. fn extract_deps_from_config<'a>( allocator: &'a Allocator, config_obj: &'a oxc_ast::ast::ObjectExpression<'a>, source_text: Option<&'a str>, consts: Option<&StringConsts<'_>>, -) -> Vec<'a, DependencyMetadata<'a>> { - let mut deps = Vec::new_in(&allocator); - +) -> Option>> { for prop in &config_obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop && is_metadata_property(prop) { if let Some(key_name) = get_property_key_name(&prop.key) { if key_name.as_str() == "deps" { - if let Expression::ArrayExpression(arr) = &prop.value { - for element in &arr.elements { - if let Some(dep) = - extract_dependency(allocator, element, source_text, consts) - { - deps.push(dep); - } + let Expression::ArrayExpression(arr) = &prop.value else { return None }; + let mut deps = Vec::new_in(&allocator); + for element in &arr.elements { + if let Some(dep) = + extract_dependency(allocator, element, source_text, consts) + { + deps.push(dep); } } - break; + return Some(deps); } } } } - deps + None } /// Angular's DI flags a `deps` array entry can hold, see [`extract_dependency`]. diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 255dcfff0..36e2712d8 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -33,22 +33,38 @@ //! println!("{}", result.code); //! ``` +use std::borrow::Cow; + use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrayExpressionElement, BindingPattern, CallExpression, Decorator, - ExportDefaultDeclaration, Expression, ExpressionStatement, ObjectExpression, - ObjectPropertyKind, Program, PropertyKey, Statement, + ExportDefaultDeclaration, Expression, ExpressionStatement, Function, IdentifierReference, + MethodDefinition, ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, PropertyKey, + PropertyKind, Statement, }; use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; use oxc_span::{GetSpan, SourceType, Span}; +use oxc_syntax::reference::ReferenceId; +use oxc_syntax::scope::{ScopeFlags, ScopeId}; +use rustc_hash::FxHashMap; use crate::AngularVersion; +use crate::directive::split_query_predicate; use crate::optimizer::Edit; +use crate::output::ast::{ + DeclareVarStmt, LiteralArrayExpr, LiteralExpr, LiteralValue, OutputExpression, OutputStatement, + StmtModifier, +}; +use crate::output::emitter::JsEmitter; use crate::output::emitter::escape_string; -use crate::pipeline::constant_pool::SharedPool; +use crate::output::emitter::is_left_hand_side_source; +use crate::pipeline::constant_pool::{ConstantPool, SharedPool}; +use crate::pipeline::emit::declare_pooled_constants_from; use crate::pipeline::phases::parse_extracted_styles::parse_style_string; -use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector}; +use crate::pipeline::selector::{CssSelector, R3SelectorElement, parse_selector_to_r3_selector}; +use crate::util::lone_surrogate::js_str_to_str; /// Check if an object property key needs quoting because it contains unsafe characters. /// @@ -132,7 +148,7 @@ pub fn link_with_options( let mut edits: Vec = Vec::new(); // Walk all statements looking for ɵɵngDeclare* calls - collect_declaration_edits(&program, code, filename, options, &mut edits); + collect_declaration_edits(allocator, &program, code, filename, options, &mut edits); if edits.is_empty() { return LinkResult { code: code.to_string(), map: None, linked: false }; @@ -151,25 +167,80 @@ pub fn link_with_options( /// /// Like Babel's linker, which visits every `CallExpression`, this visits every call wherever it sits /// (class members, array and object literals, IIFE callees, conditions, decorators, ...). -fn collect_declaration_edits( - program: &Program<'_>, +fn collect_declaration_edits<'a>( + allocator: &'a Allocator, + program: &Program<'a>, source: &str, filename: &str, options: &LinkOptions, edits: &mut Vec, ) { - DeclarationCollector { + let scoping = SemanticBuilder::new().build(program).semantic.into_scoping(); + let mut collector = DeclarationCollector { + allocator, source, filename, options, edits, statement_start: None, decorator: None, + scoping, + constant_scopes: FxHashMap::default(), + method: None, + emit_scopes: FxHashMap::default(), + }; + collector.visit_program(program); + // Like Angular's linker plugin when it leaves the file: each scope's + // constants go once at its top + for scope in collector.emit_scopes.into_values() { + if !scope.statements_js.is_empty() { + // After a declaration starting at the same place, so before its text + let mut edit = Edit::insert(scope.insert_at, scope.statements_js); + edit.priority = 1; + collector.edits.push(edit); + } } - .visit_program(program); } -struct DeclarationCollector<'s> { +/// The state of a constant pool carried from one declaration to the next, and +/// between the steps of one declaration. +#[derive(Default, Clone)] +struct LinkPool<'a> { + /// The next `_c` constant index. + next_index: u32, + /// What the earlier declarations pooled and claimed. + shared: SharedPool<'a>, +} + +impl<'a> LinkPool<'a> { + /// A pool continuing this one. + fn open(&mut self, allocator: &'a Allocator) -> ConstantPool<'a> { + let mut pool = ConstantPool::with_starting_index(allocator, self.next_index); + pool.inherit_shared(std::mem::take(&mut self.shared)); + pool + } + + /// Carries what `pool` pooled on to the next one. + fn close(&mut self, pool: &mut ConstantPool<'a>) { + self.next_index = pool.next_name_index(); + self.shared = pool.take_shared(); + } +} + +/// The declarations whose `ngImport` is bound in one module or function scope, +/// like an `EmitScope` of Angular's `FileLinker`: they share one constant pool, +/// whose statements go once at the top of that scope. +struct EmitScope<'a> { + /// Where the statements go (see [`DeclarationCollector::constant_scopes`]). + insert_at: u32, + /// The scope's pool. + pool: LinkPool<'a>, + /// The pooled constants and child template functions, as JS. + statements_js: String, +} + +struct DeclarationCollector<'s, 'a> { + allocator: &'a Allocator, source: &'s str, filename: &'s str, options: &'s LinkOptions, @@ -179,17 +250,184 @@ struct DeclarationCollector<'s> { statement_start: Option<(Span, bool)>, /// The expression of the decorator being visited decorator: Option, + scoping: Scoping, + /// The scopes that can hold shared constants, as Angular's `BabelDeclarationScope` allows them: a module, where + /// they go before the first statement that is not an import, and a function declaration or expression (not a + /// method or an arrow function), where they go at the start of its body + constant_scopes: FxHashMap, + /// The function of the method being visited + method: Option, + emit_scopes: FxHashMap>, } -impl DeclarationCollector<'_> { - fn visit_statement_expression<'a>(&mut self, expr: &Expression<'a>, is_statement: bool) { +impl<'a> DeclarationCollector<'_, 'a> { + fn visit_statement_expression(&mut self, expr: &Expression<'a>, is_statement: bool) { let outer = self.statement_start.replace((expr.span(), is_statement)); self.visit_expression(expr); self.statement_start = outer; } + + /// The scope whose constant pool a declaration with `ng_import` uses, like + /// `BabelDeclarationScope.getConstantScopeRef`: the scope binding the + /// namespace, if it can hold constants. `None` keeps the constants local. + fn constant_scope(&self, ng_import: &Expression<'a>) -> Option { + let mut expr = ng_import.without_parentheses(); + while let Some(member) = expr.as_member_expression() { + expr = member.object().without_parentheses(); + } + let Expression::Identifier(ident) = expr else { return None }; + let symbol = self.scoping.get_reference(ident.reference_id.get()?).symbol_id()?; + let scope = self.scoping.symbol_scope_id(symbol); + self.constant_scopes.contains_key(&scope).then_some(scope) + } + + /// Whether a `@defer` dependency function of `meta` uses a binding of a scope + /// inside `scope`, such as a class declared in the function that declares + /// the component. At the top of `scope` it would throw, so unlike Angular's + /// linker, which moves it there anyway, it stays local. + fn defer_deps_use_inner_binding(&self, meta: &ObjectExpression<'a>, scope: ScopeId) -> bool { + let Some(deps) = get_array_property(meta, "deferBlockDependencies") else { return false }; + let mut references = ReferenceCollector(Vec::new()); + references.visit_array_expression(deps); + references.0.into_iter().any(|reference| { + let Some(symbol) = self.scoping.get_reference(reference).symbol_id() else { + return false; + }; + let symbol_scope = self.scoping.symbol_scope_id(symbol); + // Neither visible from the top of `scope` nor bound in the functions themselves + !self.scoping.scope_ancestors(scope).any(|ancestor| ancestor == symbol_scope) + && !deps.span.contains_inclusive(self.scoping.symbol_span(symbol)) + }) + } + + /// Links a declaration, with the pool of its constant scope or one of its own. + fn link(&mut self, name: &str, call: &CallExpression<'a>) -> Option { + let meta = get_metadata_object(call)?; + let ng_import = get_property_expression(meta, "ngImport").or(match &call.callee { + Expression::StaticMemberExpression(member) => Some(&member.object), + _ => None, + }); + let Some(scope) = ng_import + .and_then(|ng_import| self.constant_scope(ng_import)) + .filter(|&scope| !self.defer_deps_use_inner_binding(meta, scope)) + else { + // Angular's `LocalEmitScope`: the constants go in an IIFE with the definition + let mut pool = LinkPool::default(); + let (definition, statements) = link_declaration( + name, + call, + self.source, + self.filename, + self.options, + self.allocator, + &mut pool, + )?; + let replacement = if statements.is_empty() { + definition + } else { + // The IIFE only declares the constants and child template functions + // of the pure define call, so it is pure too: without the annotation + // bundlers would keep unused classes + format!("/*@__PURE__*/ (() => {{\n{statements}\nreturn {definition};\n}})()") + }; + return Some(Edit::replace(call.span.start, call.span.end, replacement)); + }; + let insert_at = self.constant_scopes[&scope]; + let emit_scope = self.emit_scopes.entry(scope).or_insert_with(|| EmitScope { + insert_at, + pool: LinkPool::default(), + statements_js: String::new(), + }); + // A declaration that fails to link pools nothing + let saved = emit_scope.pool.clone(); + let Some((definition, statements)) = link_declaration( + name, + call, + self.source, + self.filename, + self.options, + self.allocator, + &mut emit_scope.pool, + ) else { + emit_scope.pool = saved; + return None; + }; + emit_scope.statements_js.push_str(&statements); + Some(Edit::replace(call.span.start, call.span.end, definition)) + } +} + +/// The references to bindings in a node. +struct ReferenceCollector(Vec); + +impl<'a> Visit<'a> for ReferenceCollector { + fn visit_identifier_reference(&mut self, ident: &IdentifierReference<'a>) { + self.0.extend(ident.reference_id.get()); + } } -impl<'a> Visit<'a> for DeclarationCollector<'_> { +impl<'a> Visit<'a> for DeclarationCollector<'_, 'a> { + fn visit_program(&mut self, program: &Program<'a>) { + // Babel parses a file as a module unless told otherwise, so Angular's + // linker shares the top level of any file + if let Some(scope) = program.scope_id.get() { + let first = program + .body + .iter() + .position(|stmt| !matches!(stmt, Statement::ImportDeclaration(_))) + .unwrap_or(0); + let insert_at = program.body.get(first).map_or(program.span.end, |stmt| { + let start = stmt.span().start; + if first > 0 || !program.directives.is_empty() { + // Babel prints the comments in between after the node before + start + } else { + // Before the leading comments, which Babel prints with the statement + program + .comments + .iter() + .filter(|comment| comment.is_leading() && comment.attached_to == start) + .map(|comment| comment.span.start) + .fold(start, u32::min) + } + }); + self.constant_scopes.insert(scope, insert_at); + } + walk::walk_program(self, program); + } + + fn visit_method_definition(&mut self, method: &MethodDefinition<'a>) { + let outer = self.method.replace(method.value.span); + walk::walk_method_definition(self, method); + self.method = outer; + } + + fn visit_object_property(&mut self, prop: &ObjectProperty<'a>) { + if prop.method || prop.kind != PropertyKind::Init { + let outer = self.method.replace(prop.value.span()); + walk::walk_object_property(self, prop); + self.method = outer; + } else { + walk::walk_object_property(self, prop); + } + } + + fn visit_function(&mut self, func: &Function<'a>, flags: ScopeFlags) { + if self.method != Some(func.span) + && let Some(body) = &func.body + && let Some(scope) = func.scope_id.get() + { + // Babel prints the comments after a directive with it + let insert_at = match (body.directives.last(), body.statements.first()) { + (Some(_), Some(stmt)) => stmt.span().start, + (Some(directive), None) => directive.span.end, + (None, _) => body.span.start + 1, + }; + self.constant_scopes.insert(scope, insert_at); + } + walk::walk_function(self, func, flags); + } + fn visit_statements(&mut self, stmts: &oxc_allocator::Vec<'a, Statement<'a>>) { for stmt in stmts { match stmt { @@ -221,8 +459,7 @@ impl<'a> Visit<'a> for DeclarationCollector<'_> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { if let Some(name) = get_declare_name(call) - && let Some(mut edit) = - link_declaration(name, call, self.source, self.filename, self.options) + && let Some(mut edit) = self.link(name, call) { // A linked factory is a `function` expression: where it starts a longer statement, such as // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and, in a @@ -831,14 +1068,17 @@ fn get_factory_target(obj: &ObjectExpression<'_>, source: &str) -> &'static str "Injectable" } -/// Link a single ɵɵngDeclare* call, generating the replacement code. -fn link_declaration( +/// Link a single ɵɵngDeclare* call, generating the definition and the +/// statements its constants and child template functions need, with `pool`. +fn link_declaration<'a>( name: &str, call: &CallExpression<'_>, source: &str, filename: &str, options: &LinkOptions, -) -> Option { + allocator: &'a Allocator, + pool: &mut LinkPool<'a>, +) -> Option<(String, String)> { let meta = get_metadata_object(call)?; let ns = get_ng_import_namespace(call); let type_name = get_property_source(meta, "type", source)?; @@ -852,13 +1092,16 @@ fn link_declaration( DECLARE_PIPE => link_pipe(meta, source, ns, type_name), DECLARE_CLASS_METADATA => link_class_metadata(meta, source, ns, type_name), DECLARE_CLASS_METADATA_ASYNC => link_class_metadata_async(meta, source, ns, type_name), - DECLARE_DIRECTIVE => link_directive(meta, source, ns, type_name, options), - DECLARE_COMPONENT => link_component(meta, source, filename, ns, type_name, options), + DECLARE_DIRECTIVE => { + return Some(link_directive(meta, source, ns, type_name, options, allocator, pool)); + } + DECLARE_COMPONENT => { + return link_component(meta, source, filename, ns, type_name, options, allocator, pool); + } _ => return None, }; - let replacement = replacement?; - Some(Edit::replace(call.span.start, call.span.end, replacement)) + Some((replacement?, String::new())) } /// Link ɵɵngDeclareFactory → factory function. @@ -905,6 +1148,22 @@ fn link_factory( } } +/// Parenthesize source that will be the object of a property access, as TS's +/// `parenthesizeLeftSideOfAccess` does when Angular's linker builds +/// `useClass.ɵfac`. Without it, `flag ? A : B` would read `B.ɵfac` alone and +/// `Maybe?.cls` would pull `.ɵfac` into its optional chain. +fn parenthesize_left_side_of_access(source: &str) -> Cow<'_, str> { + let allocator = Allocator::default(); + let is_optional_chain = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .is_ok_and(|expr| matches!(expr, Expression::ChainExpression(_))); + if is_optional_chain || !is_left_hand_side_source(source) { + Cow::Owned(format!("({source})")) + } else { + Cow::Borrowed(source) + } +} + /// Generate the conditional factory pattern used by Angular for injectable linking. /// `ctor_type` is used in the if-branch: `new (ctor_type)()`. /// `non_ctor_expr` is used in the else-branch. @@ -953,7 +1212,8 @@ fn link_injectable( } // useClass without deps: delegate to useClass's own factory if use_class != type_name { - // Case 7: useClass !== type without deps — already correct + // Case 7: useClass !== type without deps + let use_class = parenthesize_left_side_of_access(use_class); return Some(format!( "/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory(__ngFactoryType__) {{ return {use_class}.\u{0275}fac(__ngFactoryType__); }}{provided_in_suffix} }})" )); @@ -1378,30 +1638,28 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source } /// Link ɵɵngDeclareDirective → ɵɵdefineDirective. -fn link_directive( +fn link_directive<'a>( meta: &ObjectExpression<'_>, source: &str, ns: &str, type_name: &str, options: &LinkOptions, -) -> Option { + allocator: &'a Allocator, + pool: &mut LinkPool<'a>, +) -> (String, String) { let mut parts = vec![format!("type: {type_name}")]; if let Some(selector) = get_string_property(meta, "selector") { parts.push(format!("selectors: {}", parse_selector(selector))); } - // Content queries — convert queries array to contentQueries function - if let Some(queries_arr) = get_array_property(meta, "queries") - && let Some(cq_fn) = build_queries(queries_arr, source, ns, type_name, true) - { + // Content queries, then view queries — pooled before the host bindings + let LinkedQueries { content_queries, view_query, mut declarations_js } = + link_queries(allocator, pool, meta, source, ns, type_name, options.angular_version); + if let Some(cq_fn) = content_queries { parts.push(format!("contentQueries: {cq_fn}")); } - - // View queries — convert viewQueries array to viewQuery function - if let Some(view_queries_arr) = get_array_property(meta, "viewQueries") - && let Some(vq_fn) = build_queries(view_queries_arr, source, ns, type_name, false) - { + if let Some(vq_fn) = view_query { parts.push(format!("viewQuery: {vq_fn}")); } @@ -1430,7 +1688,6 @@ fn link_directive( } // Host bindings (hostAttrs, hostVars, hostBindings) - let mut host_binding_declarations_js = String::new(); if let Some(host_obj) = get_object_property(meta, "host") { // Static attributes → hostAttrs let host_attrs = build_host_attrs(host_obj, source); @@ -1442,38 +1699,27 @@ fn link_directive( let host_input = extract_host_metadata_input(host_obj); let selector = get_string_property(meta, "selector"); if let Some(host_output) = crate::component::compile_host_bindings_for_linker( - &Allocator::default(), + allocator, &host_input, type_name, selector, - 0, // directives have no template, so pool starts at 0 - &mut SharedPool::default(), + pool.next_index, + &mut pool.shared, ns, options.angular_version, ) { + pool.next_index = host_output.next_pool_index; if host_output.host_vars > 0 { parts.push(format!("hostVars: {}", host_output.host_vars)); } parts.push(format!("hostBindings: {}", host_output.fn_js)); - if !host_output.declarations_js.is_empty() { - host_binding_declarations_js = host_output.declarations_js; - } + declarations_js.push_str(&host_output.declarations_js); } } let define_call = format!("/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineDirective({{ {} }})", parts.join(", ")); - if host_binding_declarations_js.is_empty() { - Some(define_call) - } else { - // The IIFE only declares the host binding functions and constants before - // returning the pure define call, so it is pure too. Without the annotation - // bundlers keep unused directives (Angular's linker hoists the - // declarations and leaves the define call itself at the top level). - Some(format!( - "/*@__PURE__*/ (() => {{\n{host_binding_declarations_js}\nreturn {define_call};\n}})()" - )) - } + (define_call, declarations_js) } /// Extract an array expression property value from an object expression. @@ -1568,6 +1814,8 @@ fn build_queries( ns: &str, type_name: &str, is_content_query: bool, + pool: &mut ConstantPool<'_>, + angular_version: Option, ) -> Option { if queries.elements.is_empty() { return None; @@ -1576,6 +1824,21 @@ fn build_queries( let mut create_stmts: Vec = Vec::new(); let mut update_stmts: Vec = Vec::new(); let mut t_declared = false; + // Like `create_view_queries_function`, consecutive creation calls of the + // same instruction are chained (`ɵɵviewQuery(a, 5)(b, 5)`) on runtimes + // whose query instructions return themselves. `Some(is_signal)` while the + // last statement can take another call. + let chain_emit = angular_version.is_none_or(|v| v.supports_chained_queries()); + let mut chain_is_signal: Option = None; + // Consecutive signal queries advance once, by their count, as with + // Angular's `collapseAdvanceStatements` + let mut advances = 0u32; + let flush_advances = + |update_stmts: &mut Vec, advances: &mut u32| match std::mem::take(advances) { + 0 => {} + 1 => update_stmts.push(format!("{ns}.\u{0275}\u{0275}queryAdvance()")), + count => update_stmts.push(format!("{ns}.\u{0275}\u{0275}queryAdvance({count})")), + }; for el in &queries.elements { let expr = match el { @@ -1596,17 +1859,23 @@ fn build_queries( // For type references, unwrap forwardRef() (TS linker: extractForwardRef(predicateExpr)). let predicate = if let Some(expr) = get_property_expression(query_obj.as_ref(), "predicate") { - if matches!(expr, Expression::ArrayExpression(_)) { - // String array predicate — pass through as-is - let span = expr.span(); - &source[span.start as usize..span.end as usize] + if let Expression::ArrayExpression(selectors) = expr { + // String array predicate — split into its refs and pooled as a + // constant (Angular's `getConstLiteral(..., true)`), or passed + // through as-is + query_selectors(selectors).map_or_else( + || source[expr.span().start as usize..expr.span().end as usize].to_string(), + |selectors| { + JsEmitter::new().emit_expression(&pool_string_array(pool, &selectors)) + }, + ) } else { // Type reference — unwrap forwardRef if present let (src, _) = extract_forward_ref(expr, source); - src + src.to_string() } } else { - "null" + "null".to_string() }; // Calculate flags: DESCENDANTS=1, IS_STATIC=2, EMIT_DISTINCT_CHANGES_ONLY=4 @@ -1627,38 +1896,32 @@ fn build_queries( } // Create block — signal queries use different instructions with ctx.propertyName - if is_content_query { - if is_signal { - let mut args = format!("dirIndex, ctx.{prop_name}, {predicate}, {flags}"); - if let Some(read_expr) = read { - args = format!("{args}, {read_expr}"); - } - create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuerySignal({args})")); - } else { - let mut args = format!("dirIndex, {predicate}, {flags}"); - if let Some(read_expr) = read { - args = format!("{args}, {read_expr}"); - } - create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuery({args})")); + let (instruction, mut args) = match (is_content_query, is_signal) { + (true, true) => { + ("contentQuerySignal", format!("dirIndex, ctx.{prop_name}, {predicate}, {flags}")) } - } else if is_signal { - let mut args = format!("ctx.{prop_name}, {predicate}, {flags}"); - if let Some(read_expr) = read { - args = format!("{args}, {read_expr}"); - } - create_stmts.push(format!("{ns}.\u{0275}\u{0275}viewQuerySignal({args})")); - } else { - let mut args = format!("{predicate}, {flags}"); - if let Some(read_expr) = read { - args = format!("{args}, {read_expr}"); + (true, false) => ("contentQuery", format!("dirIndex, {predicate}, {flags}")), + (false, true) => ("viewQuerySignal", format!("ctx.{prop_name}, {predicate}, {flags}")), + (false, false) => ("viewQuery", format!("{predicate}, {flags}")), + }; + if let Some(read_expr) = read { + args = format!("{args}, {read_expr}"); + } + match create_stmts.last_mut() { + Some(chain) if chain_is_signal == Some(is_signal) => { + chain.push('('); + chain.push_str(&args); + chain.push(')'); } - create_stmts.push(format!("{ns}.\u{0275}\u{0275}viewQuery({args})")); + _ => create_stmts.push(format!("{ns}.\u{0275}\u{0275}{instruction}({args})")), } + chain_is_signal = chain_emit.then_some(is_signal); // Update block — signal queries just advance; regular queries refresh+assign if is_signal { - update_stmts.push(format!("{ns}.\u{0275}\u{0275}queryAdvance()")); + advances += 1; } else { + flush_advances(&mut update_stmts, &mut advances); let t_var = if t_declared { "" } else { @@ -1672,6 +1935,8 @@ fn build_queries( } } + flush_advances(&mut update_stmts, &mut advances); + let create_block = create_stmts.join(";\n"); let update_block = update_stmts.join(";\n"); @@ -1686,6 +1951,113 @@ fn build_queries( } } +/// The refs of a string array query predicate, or `None` if an entry is not a +/// static string. +/// +/// Each entry may hold comma-separated refs (`"a, b"`), split and trimmed with +/// the empty parts kept (`"x, "` gives `x` and an empty ref), as Angular's +/// `getQueryPredicate` does (see [`split_query_predicate`]). An entry with a +/// lone surrogate (`"\uD800"`) is kept as described in +/// [`crate::util::lone_surrogate`], so that it is pooled like any other. +fn query_selectors<'a>( + selectors: &'a oxc_ast::ast::ArrayExpression<'a>, +) -> Option>> { + let mut refs = Vec::new(); + for el in &selectors.elements { + let selector = match el.as_expression()? { + Expression::StringLiteral(lit) => lit.value, + Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => { + tl.quasis.first()?.value.cooked? + } + _ => return None, + }; + match js_str_to_str(selector) { + Cow::Borrowed(selector) => { + refs.extend(split_query_predicate(selector).map(Cow::Borrowed)); + } + Cow::Owned(selector) => refs.extend( + split_query_predicate(&selector).map(|selector| Cow::Owned(selector.to_string())), + ), + } + } + Some(refs) +} + +/// Pool an array of strings as one constant, as Angular's `getQueryPredicate` +/// (for the refs of a string query predicate) and v21's +/// `compileComponentFromMetadata` (for `attrs`) do with +/// `getConstLiteral(..., true)`. +fn pool_string_array<'a>( + pool: &mut ConstantPool<'a>, + values: &[impl AsRef], +) -> OutputExpression<'a> { + use oxc_allocator::FromIn; + use oxc_str::Ident; + + let allocator = pool.allocator(); + let mut entries = oxc_allocator::Vec::with_capacity_in(values.len(), &allocator); + for value in values { + entries.push(OutputExpression::Literal(oxc_allocator::Box::new_in( + LiteralExpr { + value: LiteralValue::String(Ident::from_in(value.as_ref(), allocator)), + source_span: None, + }, + &allocator, + ))); + } + let predicate = OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + LiteralArrayExpr { entries, source_span: None }, + &allocator, + )); + pool.get_const_literal(predicate, true) +} + +/// The `contentQueries` and `viewQuery` functions of a directive or component. +struct LinkedQueries { + /// The `contentQueries` function as JS, if any. + content_queries: Option, + /// The `viewQuery` function as JS, if any. + view_query: Option, + /// The pooled predicates' declarations as JS. + declarations_js: String, +} + +/// Build the query functions of a declaration, pooling their string +/// predicates first: Angular's `baseDirectiveFields` compiles content queries, +/// then view queries, before the host bindings, all with one pool. +fn link_queries<'a>( + allocator: &'a Allocator, + link_pool: &mut LinkPool<'a>, + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, + angular_version: Option, +) -> LinkedQueries { + let mut pool = link_pool.open(allocator); + let content_queries = get_array_property(meta, "queries").and_then(|queries| { + build_queries(queries, source, ns, type_name, true, &mut pool, angular_version) + }); + let view_query = get_array_property(meta, "viewQueries").and_then(|queries| { + build_queries(queries, source, ns, type_name, false, &mut pool, angular_version) + }); + + let declarations_js = declare_pooled_constants_js(&mut pool, ns); + link_pool.close(&mut pool); + LinkedQueries { content_queries, view_query, declarations_js } +} + +/// The declarations of the constants in `pool` as JS. +fn declare_pooled_constants_js(pool: &mut ConstantPool<'_>, ns: &str) -> String { + let emitter = JsEmitter::with_core_namespace(ns); + let mut declarations_js = String::new(); + for decl in &declare_pooled_constants_from(pool, 0) { + declarations_js.push_str(&emitter.emit_statement(decl)); + declarations_js.push('\n'); + } + declarations_js +} + /// Build the features array from component metadata. /// /// Examines boolean flags and providers to build the features array: @@ -1744,6 +2116,24 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } } +/// Whether a component is not standalone or has a directive, component or NgModule +/// dependency, as Angular's linker sets `hasDirectiveDependencies` (before the `@defer` +/// blocks' dependencies, which [`crate::component::compile_template_for_linker`] adds). +fn has_directive_dependencies(meta: &ObjectExpression<'_>) -> bool { + let standalone = get_bool_property(meta, "isStandalone") + .unwrap_or_else(|| get_default_standalone_value(meta)); + !standalone + || get_array_property(meta, "dependencies").is_some_and(|deps| { + deps.elements.iter().any(|dep| match dep.as_expression() { + Some(Expression::ObjectExpression(dep)) => matches!( + get_string_property(dep, "kind"), + Some("directive" | "component" | "ngmodule") + ), + _ => false, + }) + }) +} + /// Link ɵɵngDeclareComponent → ɵɵdefineComponent. /// /// A component extends a directive with template compilation and additional @@ -1757,24 +2147,23 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option /// return i0.ɵɵdefineComponent({ ... template: Component_Template, ... }); /// })() /// ``` -fn link_component( +#[expect(clippy::too_many_arguments)] +fn link_component<'a>( meta: &ObjectExpression<'_>, source: &str, filename: &str, ns: &str, type_name: &str, options: &LinkOptions, -) -> Option { + allocator: &'a Allocator, + pool: &mut LinkPool<'a>, +) -> Option<(String, String)> { // Extract template string - required for component linking let template = get_string_property(meta, "template")?; let preserve_whitespaces = get_bool_property(meta, "preserveWhitespaces").unwrap_or(false); // Compile the template using the full template compilation pipeline. - let template_allocator = Allocator::default(); - - // We need to leak the template string into the template allocator's lifetime - let template_owned: String = template.to_string(); - let template_ref: &str = template_allocator.alloc_str(&template_owned); + let template_ref: &str = allocator.alloc_str(template); // `deferBlockDependencies: [() => [import("./a").then(m => m.A)], null]`, // one entry per `@defer` block, emitted as written. Parentheses are not part @@ -1794,28 +2183,50 @@ fn link_component( }) .unwrap_or_default(); - // Angular's `compileComponentFromMetadata` compiles the host bindings (in - // `baseDirectiveFields`) before the template, with one pool: the host - // bindings' constants come first, and the template reuses equal ones. + // Angular's `compileComponentFromMetadata` compiles the queries and the + // host bindings (in `baseDirectiveFields`) before the template, with one + // pool: the query predicates come first, then the host bindings' + // constants, and the template reuses equal ones. + let LinkedQueries { content_queries, view_query, declarations_js: query_declarations_js } = + link_queries(allocator, pool, meta, source, ns, type_name, options.angular_version); let host_obj = get_object_property(meta, "host"); - let mut shared_pool = SharedPool::default(); let host_output = host_obj.and_then(|host_obj| { // Extract host properties and listeners as HostMetadataInput and compile // through the full Angular expression parser for correct output. crate::component::compile_host_bindings_for_linker( - &template_allocator, + allocator, &extract_host_metadata_input(host_obj), type_name, get_string_property(meta, "selector"), - 0, - &mut shared_pool, + pool.next_index, + &mut pool.shared, ns, options.angular_version, ) }); + if let Some(host_output) = &host_output { + pool.next_index = host_output.next_pool_index; + } + + // Pool the `attrs` const when the first selector has attributes, after the + // host bindings and before the template. Only Angular before + // v22.0.0-next.4 adds it, in `compileComponentFromMetadata` after + // `baseDirectiveFields`. + let mut attrs_pool = pool.open(allocator); + let attrs = get_string_property(meta, "selector") + .filter(|_| options.angular_version.is_some_and(|v| v.emits_selector_attrs())) + .and_then(|selector| CssSelector::parse(selector).into_iter().next()) + .map(|first_selector| first_selector.get_attrs()) + .filter(|attrs| !attrs.is_empty()) + .map(|attrs| { + let attrs: Vec<&str> = attrs.iter().map(String::as_str).collect(); + JsEmitter::new().emit_expression(&pool_string_array(&mut attrs_pool, &attrs)) + }); + let attrs_declarations_js = declare_pooled_constants_js(&mut attrs_pool, ns); + pool.close(&mut attrs_pool); let template_output = crate::component::compile_template_for_linker( - &template_allocator, + allocator, template_ref, type_name, filename, @@ -1823,10 +2234,12 @@ fn link_component( ns, options.angular_version, &defer_block_deps, - host_output.as_ref().map_or(0, |host_output| host_output.next_pool_index), - shared_pool, + has_directive_dependencies(meta), + pool.next_index, + &mut pool.shared, ) .ok()?; + pool.next_index = template_output.next_pool_index; // Build the defineComponent properties let mut parts: Vec = Vec::new(); @@ -1841,16 +2254,12 @@ fn link_component( } // 3. contentQueries - if let Some(queries_arr) = get_array_property(meta, "queries") - && let Some(cq_fn) = build_queries(queries_arr, source, ns, type_name, true) - { + if let Some(cq_fn) = content_queries { parts.push(format!("contentQueries: {cq_fn}")); } // 4. viewQuery - if let Some(view_queries_arr) = get_array_property(meta, "viewQueries") - && let Some(vq_fn) = build_queries(view_queries_arr, source, ns, type_name, false) - { + if let Some(vq_fn) = view_query { parts.push(format!("viewQuery: {vq_fn}")); } @@ -1907,6 +2316,11 @@ fn link_component( parts.push(format!("features: {features}")); } + // 12b. attrs (pooled above, before Angular v22) + if let Some(attrs) = attrs { + parts.push(format!("attrs: {attrs}")); + } + // 13. ngContentSelectors (from template compilation) if let Some(ref ng_content_selectors) = template_output.ng_content_selectors_js { parts.push(format!("ngContentSelectors: {ng_content_selectors}")); @@ -1923,8 +2337,8 @@ fn link_component( parts.push(format!("consts: {consts}")); } - // 17. template (reference to the compiled function) - parts.push(format!("template: {}", template_output.template_fn_name)); + // 17. template + parts.push(format!("template: {}", template_output.template_fn_js)); // 18. dependencies — support both new-style (v14+) and old-style (v12-v13) fields // Aligns with TS linker's `extractDeclarationTypeExpr` which calls `extractForwardRef` @@ -2035,30 +2449,80 @@ fn link_component( // Process styles: apply CSS scoping for Emulated encapsulation let mut has_styles = false; + let mut styles_declarations_js = String::new(); if let Some(styles_arr) = get_array_property(meta, "styles") { - let mut scoped_styles: Vec = Vec::new(); + // A long style is shared once the scope uses it twice (Angular's + // `constantPool.getConstLiteral(literal(style))`, after the template) + let mut styles_pool = pool.open(allocator); + let emitter = JsEmitter::with_core_namespace(ns); + let mut scoped_styles: Vec> = Vec::new(); for el in &styles_arr.elements { // `as_expression` returns None for spreads and elisions (e.g. `[, "a"]`), // where `to_expression` would panic. let Some(expr) = el.as_expression() else { continue }; - if let Some(style) = as_static_string(expr) { - if is_emulated { - let scoped = crate::styles::shim_css_text_for_version( - style, - "_ngcontent-%COMP%", - "_nghost-%COMP%", - options.angular_version, - ); - if !scoped.trim().is_empty() { - scoped_styles.push(crate::output::emitter::escape_string(&scoped, false)); - } - } else if !style.trim().is_empty() { - scoped_styles.push(crate::output::emitter::escape_string(style, false)); - } + let Some(style) = as_static_string(expr) else { continue }; + let style = if is_emulated { + Cow::Owned(crate::styles::shim_css_text_for_version( + style, + "_ngcontent-%COMP%", + "_nghost-%COMP%", + options.angular_version, + )) + } else { + Cow::Borrowed(style) + }; + if style.trim().is_empty() { + continue; + } + let pooled_before = styles_pool.len(); + scoped_styles.push(styles_pool.get_const_literal( + OutputExpression::Literal(oxc_allocator::Box::new_in( + LiteralExpr { + value: LiteralValue::String(oxc_allocator::FromIn::from_in( + style.as_ref(), + allocator, + )), + source_span: None, + }, + &allocator, + )), + false, + )); + // Declared in the order they are pooled. A style an earlier + // declaration used inline keeps it there: Angular's linker has + // emitted that declaration already. + for decl in &declare_pooled_constants_from(&mut styles_pool, pooled_before) { + styles_declarations_js.push_str(&emitter.emit_statement(decl)); + styles_declarations_js.push('\n'); + } + for fixup in styles_pool.take_inherited_fixups() { + styles_declarations_js.push_str(&emitter.emit_statement( + &OutputStatement::DeclareVar(oxc_allocator::Box::new_in( + DeclareVarStmt { + name: fixup.name, + value: Some(fixup.value), + modifiers: StmtModifier::FINAL, + leading_comment: None, + source_span: None, + }, + &allocator, + )), + )); + styles_declarations_js.push('\n'); + } + } + // A style used twice here was inline on its first use; point that use + // at the constant too, like Angular's `FixupExpression` does. + for entry in &mut scoped_styles { + if let Some(shared) = styles_pool.shared_literal(entry) { + *entry = shared; } } + pool.close(&mut styles_pool); if !scoped_styles.is_empty() { has_styles = true; + let scoped_styles: Vec = + scoped_styles.iter().map(|style| emitter.emit_expression(style)).collect(); parts.push(format!("styles: [{}]", scoped_styles.join(", "))); } } @@ -2109,17 +2573,17 @@ fn link_component( let define_component = format!("/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineComponent({{ {} }})", parts.join(", ")); - // Wrap in IIFE with host binding and template declarations, in the order - // they were pooled - let mut declarations = host_binding_declarations_js; + // The query, host binding, attrs, template and style declarations, in the + // order they were pooled + let mut declarations = query_declarations_js; + declarations.push_str(&host_binding_declarations_js); + declarations.push_str(&attrs_declarations_js); declarations.push_str(&template_output.declarations_js); - if declarations.trim().is_empty() { - Some(define_component) - } else { - // Pure for the same reason as in `link_directive`: it only declares the - // template functions and pooled constants used by the define call. - Some(format!("/*@__PURE__*/ (() => {{\n{declarations}\nreturn {define_component};\n}})()")) + if !declarations.is_empty() && !declarations.ends_with('\n') { + declarations.push('\n'); } + declarations.push_str(&styles_declarations_js); + Some((define_component, declarations)) } #[cfg(test)] @@ -2349,6 +2813,40 @@ MyService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: " ); } + #[test] + fn test_link_injectable_use_class_parenthesized_before_fac() { + // Angular's linker builds `useClass.ɵfac` as an AST, so a conditional or an + // optional chain gets wrapped: `(flag ? A : B).ɵfac(...)`. Pasting the source + // as text would read `B.ɵfac` alone, or extend the chain `Maybe?.cls.ɵfac`. + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class S1 {} +S1.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: S1, providedIn: 'root', useClass: Maybe?.cls }); +class S2 {} +S2.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: S2, providedIn: 'root', useClass: i0.forwardRef(() => flag ? A : B) }); +class S3 {} +S3.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: S3, providedIn: 'root', useClass: Ns.Other }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("return (Maybe?.cls).\u{0275}fac(__ngFactoryType__);"), + "optional chain should be parenthesized, got:\n{}", + result.code + ); + assert!( + result.code.contains("return (flag ? A : B).\u{0275}fac(__ngFactoryType__);"), + "conditional should be parenthesized, got:\n{}", + result.code + ); + assert!( + result.code.contains("return Ns.Other.\u{0275}fac(__ngFactoryType__);"), + "plain member access should stay bare, got:\n{}", + result.code + ); + } + #[test] fn test_link_injectable_provided_in_null_omitted() { // When providedIn is absent, it should be omitted from output (not "providedIn: null"). @@ -2679,7 +3177,7 @@ C.ɵcmp = e.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ng "#; let result = link(&allocator, code, "test.mjs"); assert!(result.linked, "Component should be linked"); - assert!(result.code.contains("e.\u{0275}\u{0275}elementStart"), "got:\n{}", result.code); + assert!(result.code.contains("e.\u{0275}\u{0275}domElementStart"), "got:\n{}", result.code); assert!(!result.code.contains("i0"), "Should not reference i0, got:\n{}", result.code); } diff --git a/crates/oxc_angular_compiler/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index 7e939f92d..bcf974335 100644 --- a/crates/oxc_angular_compiler/src/output/ast.rs +++ b/crates/oxc_angular_compiler/src/output/ast.rs @@ -1587,6 +1587,36 @@ fn statement_is_equivalent(a: &OutputStatement<'_>, b: &OutputStatement<'_>) -> } } +/// Clone a user-supplied callee before it is called, or a receiver before a +/// property is read off it, wrapping an optional chain in parentheses. +/// +/// Ported from TypeScript's `parenthesizeLeftSideOfAccess`, +/// which `ts.factory.createCallExpression` applies when ngtsc calls a user +/// factory: `(Maybe?.make)()` throws when `Maybe` is nullish, while +/// `Maybe?.make()` would short-circuit to `undefined`. Property access does +/// the same: `(Maybe?.cls).ɵfac`. +pub fn clone_callee<'a>( + expr: &OutputExpression<'a>, + allocator: &'a oxc_allocator::Allocator, +) -> OutputExpression<'a> { + fn is_optional_chain(expr: &OutputExpression<'_>) -> bool { + match expr { + OutputExpression::ReadProp(e) => e.optional || is_optional_chain(&e.receiver), + OutputExpression::ReadKey(e) => e.optional || is_optional_chain(&e.receiver), + OutputExpression::InvokeFunction(e) => e.optional || is_optional_chain(&e.fn_expr), + _ => false, + } + } + let callee = expr.clone_in(allocator); + if !is_optional_chain(expr) { + return callee; + } + OutputExpression::Parenthesized(Box::new_in( + ParenthesizedExpr { expr: Box::new_in(callee, &allocator), source_span: None }, + &allocator, + )) +} + /// Clone an output statement. pub fn clone_output_statement<'a>( stmt: &OutputStatement<'a>, diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index fa96a2fb8..b198149b8 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -872,7 +872,16 @@ impl JsEmitter { } OutputExpression::Instantiate(e) => { ctx.print_with_span("new ", source_span); + // A user `useClass` like `makeClass()` must not take the + // constructor arguments: ngtsc emits `new (makeClass())(dep)`. + let should_parenthesize = new_callee_needs_parens(&e.class_expr); + if should_parenthesize { + ctx.print("("); + } self.visit_expression(&e.class_expr, ctx); + if should_parenthesize { + ctx.print(")"); + } ctx.print("("); self.visit_all_expressions(&e.args, ctx, ","); ctx.print(")"); @@ -1199,7 +1208,7 @@ fn emits_leading_brace(expr: &OutputExpression<'_>) -> bool { /// without an argument list is not one: `new X()` would call the class. /// Source that does not parse is treated as needing parentheses, which is /// always valid. -fn is_left_hand_side_source(source: &str) -> bool { +pub(crate) fn is_left_hand_side_source(source: &str) -> bool { use oxc_ast::ast::Expression; use oxc_span::GetSpan; @@ -1242,6 +1251,22 @@ fn is_left_hand_side_source(source: &str) -> bool { } } +/// Whether the callee of a `new` needs parentheses. Ported from TypeScript's +/// `parenthesizeExpressionOfNew`, which `ts.factory.createNewExpression` +/// applies when ngtsc translates `new useClass(...deps)`: a call on the +/// callee's left spine would take the arguments (`new f()(a)` constructs +/// `f`), and an optional chain is not allowed there (`new a?.b()` is a +/// SyntaxError). +fn new_callee_needs_parens(expr: &OutputExpression<'_>) -> bool { + match expr { + OutputExpression::InvokeFunction(_) | OutputExpression::ArrowFunction(_) => true, + OutputExpression::ReadProp(e) => e.optional || new_callee_needs_parens(&e.receiver), + OutputExpression::ReadKey(e) => e.optional || new_callee_needs_parens(&e.receiver), + OutputExpression::RawSource(raw) => !is_left_hand_side_source(&raw.source), + _ => false, + } +} + fn binary_operator_to_str(op: BinaryOperator) -> &'static str { match op { BinaryOperator::Equals => "==", diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 1c4e4a052..afb988a6e 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -39,7 +39,8 @@ use super::{PLACEHOLDER_VERSION, wrap_forward_ref}; use crate::component::R3DependencyMetadata as ComponentDep; use crate::component::{ ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, HostDirectiveMetadata, - HostMetadata, NamespaceRegistry, TemplateDependency, TemplateDependencyKind, ViewEncapsulation, + HostMetadata, ImportRef, NamespaceRegistry, TemplateDependency, TemplateDependencyKind, + ViewEncapsulation, }; use crate::directive::{R3InputMetadata, R3QueryMetadata}; use crate::factory::{ @@ -69,6 +70,10 @@ pub struct PartialComponentInputs<'a, 'm> { /// View queries (`@ViewChild*` / `viewChild*` signal queries / /// `queries:` view entries), emitted as `viewQueries`. pub view_queries: &'m [R3QueryMetadata<'a>], + /// Each `@defer` block's dependency function, `None` for a block with no + /// dependencies, in the order Angular's binder lists the blocks. Empty + /// when the blocks weren't resolved. + pub defer_block_dependencies: &'m [Option>], } /// Emits the `ɵɵngDeclareComponent` call for a component's `ɵcmp` static. @@ -264,8 +269,8 @@ pub fn compile_declare_component_from_metadata<'a>( // each entry (see `linker/mod.rs:1844`), so the placeholder kind/ // selector is harmless — the emitted full ɵcmp ends up with // `dependencies: [Import1, Import2, …]` regardless. - let synthetic_declarations = if meta.declarations.is_empty() && !meta.imports.is_empty() { - Some(lower_imports_to_declarations(allocator, &meta.imports)) + let synthetic_declarations = if meta.declarations.is_empty() && !meta.import_refs.is_empty() { + Some(lower_imports_to_declarations(allocator, &meta.import_refs)) } else { None }; @@ -345,19 +350,28 @@ pub fn compile_declare_component_from_metadata<'a>( )); } - // NOTE: `deferBlockDependencies` (upstream component.ts:132-153) is not - // emitted here. Upstream walks `meta.defer.blocks` and writes one - // resolver entry per `@defer` block, gated on - // `DeferBlockDepsEmitMode::PerBlock`. OXC's `ComponentMetadata` - // doesn't carry the per-block resolver map — only - // `deferred_imports: Vec` for the *class-level* async metadata - // resolver (which DOES land via `compile_declare_class_metadata_async`). - // The functional gap: components that use `@defer` blocks with - // deferrable imports get the async class metadata declaration but no - // per-block resolvers in `ɵcmp`, so the linker can't reconstruct the - // lazy-loading wiring. Closing this needs a metadata-pipeline change - // to populate per-block resolvers — out of scope for the partial - // emitter slice. + // One entry per `@defer` block, `null` for a block with no dependencies, + // and none at all when no block has any (upstream component.ts:132-153). + if inputs.defer_block_dependencies.iter().any(Option::is_some) { + let mut resolvers = Vec::new_in(&allocator); + for resolver in inputs.defer_block_dependencies { + resolvers.push(match resolver { + Some(resolver) => resolver.clone_in(allocator), + None => OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Null, source_span: None }, + &allocator, + )), + }); + } + entries.push(LiteralMapEntry::new( + Ident::from("deferBlockDependencies"), + OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: resolvers, source_span: None }, + &allocator, + )), + false, + )); + } invoke_declare(allocator, core_namespace, Identifiers::DECLARE_COMPONENT, entries) } @@ -700,27 +714,39 @@ fn host_directives_mapping_array<'a>( // ---- dependencies -------------------------------------------------------- -/// Constructs a synthetic `TemplateDependency` per identifier in -/// `imports` for partial-mode lowering when the analyzer left -/// `metadata.declarations` empty (`RuntimeResolved` mode — see callsite -/// for the why). Each entry gets `kind: directive` + placeholder -/// `selector: "*"` matching what +/// Constructs a synthetic `TemplateDependency` per class `imports` names +/// (see `ComponentMetadata::import_refs`) for partial-mode lowering when the +/// analyzer left `metadata.declarations` empty (`RuntimeResolved` mode — +/// see callsite for the why). ngtsc flattens and resolves `imports` the +/// same way, with the type information to also leave out what the template +/// doesn't use; a reference listed twice is listed once. Each entry gets +/// `kind: directive` + placeholder `selector: "*"` matching what /// `component/decorator.rs::populate_declarations_from_imports` produces /// for Direct mode. The linker reads only the `type` from each entry, so /// the placeholder fields are harmless and the linked output is /// `dependencies: [Import1, Import2, …]`. fn lower_imports_to_declarations<'a>( allocator: &'a Allocator, - imports: &Vec<'a, Ident<'a>>, + imports: &[ImportRef<'a>], ) -> Vec<'a, TemplateDependency<'a>> { - let mut out = Vec::with_capacity_in(imports.len(), &allocator); - for name in imports { - out.push(TemplateDependency::directive( + let mut out: Vec<'a, TemplateDependency<'a>> = Vec::with_capacity_in(imports.len(), &allocator); + for (index, import) in imports.iter().enumerate() { + if imports[..index].iter().any(|seen| seen.key == import.key) { + continue; + } + let mut dependency = TemplateDependency::directive( &allocator, - name.clone(), + import.name, Ident::from("*"), false, // is_component — unknown at this point - )); + ); + if let Some(namespace) = import.namespace { + dependency = dependency.with_type_namespace(namespace); + } + if import.is_forward_reference { + dependency = dependency.with_forward_reference(); + } + out.push(dependency); } out } @@ -756,10 +782,24 @@ fn create_dependencies_array_from_slice<'a>( false, )); - let mut type_expr = OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: dep.type_name.clone(), source_span: None }, - &allocator, - )); + let read_var = |name: &Ident<'a>| { + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: *name, source_span: None }, + &allocator, + )) + }; + let mut type_expr = match &dep.type_namespace { + Some(namespace) => OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in(read_var(namespace), &allocator), + name: dep.type_name, + optional: false, + source_span: None, + }, + &allocator, + )), + None => read_var(&dep.type_name), + }; if wrap_in_forward_ref || dep.is_forward_reference { type_expr = wrap_forward_ref(allocator, core_namespace, type_expr); } diff --git a/crates/oxc_angular_compiler/src/partial/factory.rs b/crates/oxc_angular_compiler/src/partial/factory.rs index 4bcce26fb..601b664bf 100644 --- a/crates/oxc_angular_compiler/src/partial/factory.rs +++ b/crates/oxc_angular_compiler/src/partial/factory.rs @@ -181,7 +181,8 @@ fn factory_target_expr<'a>( FactoryTarget::Directive => "Directive", FactoryTarget::Pipe => "Pipe", FactoryTarget::NgModule => "NgModule", - FactoryTarget::Injectable | FactoryTarget::Service => "Injectable", + FactoryTarget::Injectable => "Injectable", + FactoryTarget::Service => "Service", }; let factory_target_ref = OutputExpression::ReadProp(Box::new_in( diff --git a/crates/oxc_angular_compiler/src/partial/mod.rs b/crates/oxc_angular_compiler/src/partial/mod.rs index 62bd5e68d..16b43e328 100644 --- a/crates/oxc_angular_compiler/src/partial/mod.rs +++ b/crates/oxc_angular_compiler/src/partial/mod.rs @@ -9,6 +9,7 @@ //! Implemented: //! - `factory` — `ɵɵngDeclareFactory` //! - `injectable` — `ɵɵngDeclareInjectable` +//! - `service` — `ɵɵngDeclareService` (Angular v22+ `@Service`) //! - `pipe` — `ɵɵngDeclarePipe` //! - `ng_module` + `injector` — `ɵɵngDeclareNgModule` and //! `ɵɵngDeclareInjector`. Partial mode banishes `setNgModuleScope` @@ -45,6 +46,7 @@ pub mod injectable; pub mod injector; pub mod ng_module; pub mod pipe; +pub mod service; pub use class_metadata::{ compile_component_declare_class_metadata, compile_declare_class_metadata, @@ -57,6 +59,7 @@ pub use injectable::compile_declare_injectable_from_metadata; pub use injector::compile_declare_injector_from_metadata; pub use ng_module::compile_declare_ng_module_from_metadata; pub use pipe::compile_declare_pipe_from_metadata; +pub use service::compile_declare_service_from_metadata; use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; @@ -148,3 +151,4 @@ pub(crate) const MIN_VERSION_CLASS_METADATA: &str = "12.0.0"; pub(crate) const MIN_VERSION_PIPE: &str = "14.0.0"; pub(crate) const MIN_VERSION_NG_MODULE: &str = "14.0.0"; pub(crate) const MIN_VERSION_CLASS_METADATA_ASYNC: &str = "18.0.0"; +pub(crate) const MIN_VERSION_SERVICE: &str = "22.0.0"; diff --git a/crates/oxc_angular_compiler/src/partial/service.rs b/crates/oxc_angular_compiler/src/partial/service.rs new file mode 100644 index 000000000..ae06f2c05 --- /dev/null +++ b/crates/oxc_angular_compiler/src/partial/service.rs @@ -0,0 +1,126 @@ +//! Partial-declaration emit for `ɵɵngDeclareService`. +//! +//! Ported from upstream +//! `packages/compiler/src/render3/partial/service.ts` +//! (`compileDeclareServiceFromMetadata`). +//! +//! Emits the `ɵprov` static for a `@Service` class (Angular v22+) in library +//! form: +//! +//! ```text +//! i0.ɵɵngDeclareService({ +//! minVersion: "22.0.0", +//! version: "0.0.0-PLACEHOLDER", +//! ngImport: i0, +//! type: MyService, +//! autoProvided?: false, +//! factory?: +//! }) +//! ``` +//! +//! The ɵfac side is a `ɵɵngDeclareFactory` with `target: Service`, emitted by +//! `super::factory`. + +use oxc_allocator::{Allocator, Box, Vec}; +use oxc_str::Ident; + +use super::{MIN_VERSION_SERVICE, PLACEHOLDER_VERSION}; +use crate::output::ast::{ + InvokeFunctionExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, + OutputExpression, ReadPropExpr, ReadVarExpr, +}; +use crate::r3::Identifiers; +use crate::service::R3ServiceMetadata; + +/// Emits the `ɵɵngDeclareService` call for a `@Service`'s `ɵprov` static. +/// +/// Unlike full mode, the user's `factory` is kept as written: the linker +/// wraps it in `() => factory()`. +pub fn compile_declare_service_from_metadata<'a>( + allocator: &'a Allocator, + core_namespace: &'a str, + meta: &R3ServiceMetadata<'a>, +) -> OutputExpression<'a> { + let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); + + entries.push(string_entry(allocator, "minVersion", MIN_VERSION_SERVICE)); + entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(LiteralMapEntry::new( + Ident::from("ngImport"), + read_var(allocator, core_namespace), + false, + )); + entries.push(LiteralMapEntry::new(Ident::from("type"), meta.r#type.clone_in(allocator), false)); + + // Only emitted when explicitly disabled, like full mode. + if matches!(meta.auto_provided, Some(false)) { + entries.push(LiteralMapEntry::new( + Ident::from("autoProvided"), + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Boolean(false), source_span: None }, + &allocator, + )), + false, + )); + } + + if let Some(factory) = &meta.factory { + entries.push(LiteralMapEntry::new( + Ident::from("factory"), + factory.clone_in(allocator), + false, + )); + } + + let map_expr = OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries, source_span: None }, + &allocator, + )); + + let mut args = Vec::new_in(&allocator); + args.push(map_expr); + + OutputExpression::InvokeFunction(Box::new_in( + InvokeFunctionExpr { + fn_expr: Box::new_in( + OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in(read_var(allocator, core_namespace), &allocator), + name: Ident::from(Identifiers::DECLARE_SERVICE), + optional: false, + source_span: None, + }, + &allocator, + )), + &allocator, + ), + args, + pure: false, + optional: false, + source_span: None, + }, + &allocator, + )) +} + +fn read_var<'a>(allocator: &'a Allocator, name: &'a str) -> OutputExpression<'a> { + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Ident::from(name), source_span: None }, + &allocator, + )) +} + +fn string_entry<'a>( + allocator: &'a Allocator, + key: &'static str, + value: &'static str, +) -> LiteralMapEntry<'a> { + LiteralMapEntry::new( + Ident::from(key), + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, + &allocator, + )), + false, + ) +} diff --git a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs index 40b04c626..e17cba966 100644 --- a/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs +++ b/crates/oxc_angular_compiler/src/pipeline/constant_pool.rs @@ -55,6 +55,8 @@ pub struct ConstantPool<'a> { arrays: FxHashMap, /// All pooled values in order. values: Vec<'a, PooledConstant<'a>>, + /// For each of `values`, how many `statements` were pooled before it. + statements_before_values: std::vec::Vec, /// Counter for generating unique names (_c0, _c1, etc.). next_name_index: u32, /// Claimed names for unique_name deduplication, including the names of @@ -84,6 +86,7 @@ impl<'a> ConstantPool<'a> { strings: FxHashMap::default(), arrays: FxHashMap::default(), values: Vec::new_in(&allocator), + statements_before_values: std::vec::Vec::new(), next_name_index: 0, claimed_names: FxHashMap::default(), literals: FxHashMap::default(), @@ -115,6 +118,7 @@ impl<'a> ConstantPool<'a> { strings: FxHashMap::default(), arrays: FxHashMap::default(), values: Vec::new_in(&allocator), + statements_before_values: std::vec::Vec::new(), next_name_index: starting_index, claimed_names: FxHashMap::default(), literals: FxHashMap::default(), @@ -148,7 +152,7 @@ impl<'a> ConstantPool<'a> { let name = self.generate_name("_c"); let atom = Ident::from(self.allocator.alloc_str(value)); - self.values.push(PooledConstant { + self.push_value(PooledConstant { name: name.clone(), value: PooledValue { index }, kind: PooledConstantKind::String(atom), @@ -167,7 +171,7 @@ impl<'a> ConstantPool<'a> { let index = self.values.len() as u32; let name = self.generate_name("_c"); - self.values.push(PooledConstant { + self.push_value(PooledConstant { name, value: PooledValue { index }, kind: PooledConstantKind::ArrayPlaceholder, @@ -212,6 +216,20 @@ impl<'a> ConstantPool<'a> { Ident::from(self.allocator.alloc_str(&name)) } + /// Adds a value, after the statements pooled so far. + fn push_value(&mut self, constant: PooledConstant<'a>) { + self.statements_before_values.push(self.statements.len()); + self.values.push(constant); + } + + /// Returns how many `statements` were pooled before the value at `index`. + /// + /// Angular's `ConstantPool.statements` is one list, so a value is declared + /// after the shared functions pooled before it, and before the later ones. + pub fn statements_before(&self, index: usize) -> usize { + self.statements_before_values.get(index).copied().unwrap_or(0) + } + /// Gets the constant at the given index. pub fn get(&self, index: u32) -> Option<&PooledConstant<'a>> { self.values.get(index as usize) @@ -234,7 +252,7 @@ impl<'a> ConstantPool<'a> { let body_atom = Ident::from(self.allocator.alloc_str(body)); let flags_atom = flags.map(|f| Ident::from(self.allocator.alloc_str(f))); - self.values.push(PooledConstant { + self.push_value(PooledConstant { name: name.clone(), value: PooledValue { index }, kind: PooledConstantKind::RegularExpression(RegexConstant { @@ -288,7 +306,7 @@ impl<'a> ConstantPool<'a> { } // Store the actual body expression - self.values.push(PooledConstant { + self.push_value(PooledConstant { name: name.clone(), value: PooledValue { index }, kind: PooledConstantKind::PureFunction(Box::new_in( @@ -502,7 +520,7 @@ impl<'a> ConstantPool<'a> { self.inherited_fixups.push(InheritedFixup { name, value: literal }); } else { let index = self.values.len() as u32; - self.values.push(PooledConstant { + self.push_value(PooledConstant { name, value: PooledValue { index }, kind: PooledConstantKind::Literal(Box::new_in(literal, &self.allocator)), diff --git a/crates/oxc_angular_compiler/src/pipeline/emit.rs b/crates/oxc_angular_compiler/src/pipeline/emit.rs index d42a1bf72..c2f4ecb5b 100644 --- a/crates/oxc_angular_compiler/src/pipeline/emit.rs +++ b/crates/oxc_angular_compiler/src/pipeline/emit.rs @@ -263,10 +263,6 @@ pub struct TemplateCompilationResult<'a> { pub fn compile_template<'a>( job: &mut ComponentCompilationJob<'a>, ) -> TemplateCompilationResult<'a> { - use crate::output::ast::DeclareVarStmt; - - let allocator = job.allocator; - // Run all transformation phases (populates job.pool with constants) transform(job); @@ -274,13 +270,29 @@ pub fn compile_template<'a>( let template_fn = emit_template_fn(job); // Collect declarations from job.pool constants and statements - let mut declarations = OxcVec::new_in(&allocator); + let declarations = take_pool_declarations(&mut job.pool); - // Generate const declarations from pooled constants - // Use mutable access to allow taking ownership of expressions that can't be cloned - for constant in job.pool.constants_mut() { - let value = emit_pooled_constant_value(allocator, &mut constant.kind); + TemplateCompilationResult { template_fn, declarations } +} +/// Declares the pooled constants and moves the statements (shared and child +/// view functions) out of `pool`, in the order they were pooled, as Angular's +/// `ConstantPool.statements` is one list. +fn take_pool_declarations<'a>(pool: &mut ConstantPool<'a>) -> OxcVec<'a, OutputStatement<'a>> { + use crate::output::ast::DeclareVarStmt; + + let allocator = pool.allocator(); + let mut statements = take_statements(allocator, &mut pool.statements).into_iter(); + let mut declarations = OxcVec::new_in(&allocator); + let mut taken = 0; + for index in 0..pool.len() { + let before = pool.statements_before(index); + declarations.extend(statements.by_ref().take(before.saturating_sub(taken))); + taken = taken.max(before); + + // Use mutable access to allow taking ownership of expressions that can't be cloned + let constant = &mut pool.constants_mut()[index]; + let value = emit_pooled_constant_value(allocator, &mut constant.kind); declarations.push(OutputStatement::DeclareVar(Box::new_in( DeclareVarStmt { name: constant.name.clone(), @@ -292,11 +304,8 @@ pub fn compile_template<'a>( &allocator, ))); } - - // Move the child view function declarations out of job.pool - declarations.extend(take_statements(allocator, &mut job.pool.statements)); - - TemplateCompilationResult { template_fn, declarations } + declarations.extend(statements); + declarations } /// Declares the constants pooled from index `start` on. @@ -1628,10 +1637,6 @@ pub struct HostBindingCompilationResult<'a> { pub fn compile_host_bindings<'a>( job: &mut HostBindingCompilationJob<'a>, ) -> HostBindingCompilationResult<'a> { - use crate::output::ast::DeclareVarStmt; - - let allocator = job.allocator; - // Run all transformation phases for host bindings transform_host(job); @@ -1650,21 +1655,7 @@ pub fn compile_host_bindings<'a>( // Collect declarations from host binding pool constants. // In Angular TS, template and host binding share the same ConstantPool, // so host binding pure functions get emitted alongside template constants. - let mut declarations = OxcVec::new_in(&allocator); - for constant in job.pool.constants_mut() { - let value = emit_pooled_constant_value(allocator, &mut constant.kind); - declarations.push(OutputStatement::DeclareVar(Box::new_in( - DeclareVarStmt { - name: constant.name.clone(), - value: Some(value), - modifiers: StmtModifier::FINAL, - leading_comment: None, - source_span: None, - }, - &allocator, - ))); - } - declarations.extend(take_statements(allocator, &mut job.pool.statements)); + let declarations = take_pool_declarations(&mut job.pool); HostBindingCompilationResult { host_binding_fn, host_attrs, host_vars, declarations } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs index 7db6cea01..c8f86f6b1 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs @@ -36,6 +36,7 @@ use crate::output::ast::{ LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, StmtModifier, TypeofExpr, }; +use crate::pipeline::constant_pool::ConstantPool; /// The value of an i18n message param. /// @@ -412,34 +413,41 @@ pub fn create_translation_declaration<'a>( statements } -/// Generates an i18n variable name for the standard (non-Closure) mode. +/// Claims the next i18n variable name (`i18n_0`, `i18n_1`, ...) in `pool`. /// -/// Uses lowercase prefix to avoid Closure treating it as a constant. -pub fn generate_i18n_var_name(index: usize) -> String { - format!("{TRANSLATION_VAR_PREFIX}{index}") +/// Uses lowercase prefix to avoid Closure treating it as a constant. As in +/// Angular, the count goes through `pool.uniqueName`, so it runs across the +/// classes of a file. +pub fn generate_i18n_var_name<'a>(pool: &mut ConstantPool<'a>) -> Ident<'a> { + pool.unique_name(TRANSLATION_VAR_PREFIX, true) } -/// Generates a Closure-compatible variable name for goog.getMsg. +/// Claims a Closure-compatible variable name for goog.getMsg in `pool`. /// -/// When `use_external_ids` is true, generates: `MSG_EXTERNAL_{sanitized_id}$${suffix}` -/// When `use_external_ids` is false, generates: `MSG_{suffix}_{index}` +/// When `use_external_ids` is true, generates: `MSG_EXTERNAL_{sanitized_id}$${suffix}{n}`, +/// counting `suffix`. +/// When `use_external_ids` is false, generates: `MSG_{suffix}{n}`, counting `MSG_{suffix}`. +/// +/// Ported from Angular's `i18nGenerateClosureVar`. pub fn generate_closure_var_name( + pool: &mut ConstantPool<'_>, message_id: Option<&str>, file_suffix: &str, - index: usize, use_external_ids: bool, ) -> String { if use_external_ids { - if let Some(id) = message_id { - let sanitized_id = sanitize_identifier(id); - format!( - "{CLOSURE_TRANSLATION_VAR_PREFIX}EXTERNAL_{sanitized_id}$${file_suffix}_{index}" - ) - } else { - format!("{CLOSURE_TRANSLATION_VAR_PREFIX}{file_suffix}_{index}") + let unique_suffix = pool.unique_name(file_suffix, true); + match message_id { + Some(id) => { + let sanitized_id = sanitize_identifier(id); + format!("{CLOSURE_TRANSLATION_VAR_PREFIX}EXTERNAL_{sanitized_id}$${unique_suffix}") + } + // Angular always has an id here; the message is still counted with the others. + None => format!("{CLOSURE_TRANSLATION_VAR_PREFIX}{unique_suffix}"), } } else { - format!("{CLOSURE_TRANSLATION_VAR_PREFIX}{file_suffix}_{index}") + pool.unique_name(&format!("{CLOSURE_TRANSLATION_VAR_PREFIX}{file_suffix}"), true) + .to_string() } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs index 356354fea..fb6dd79bb 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs @@ -20,6 +20,7 @@ use crate::output::ast::{ LiteralValue, LocalizedStringExpr, OutputExpression, OutputStatement, ReadVarExpr, }; use crate::pipeline::compilation::{ComponentCompilationJob, ConstValue}; +use crate::pipeline::constant_pool::ConstantPool; use crate::pipeline::phases::i18n_closure::{ I18nMessageMeta, I18nParamExpr, create_translation_declaration, generate_closure_var_name, generate_file_based_i18n_suffix, generate_i18n_var_name, @@ -158,9 +159,6 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { // Map: i18n_context xref -> i18n variable name, for contexts owning an extracted attribute let mut attr_var_names_by_context: FxHashMap> = FxHashMap::default(); - // Counter for unique variable names - let mut i18n_var_counter: usize = 0; - // Collect view xrefs first to avoid borrowing issues let view_xrefs_for_messages: Vec = job.all_views().map(|v| v.xref).collect(); @@ -202,7 +200,7 @@ pub fn collect_i18n_consts(job: &mut ComponentCompilationJob<'_>) { &msg_info, &file_based_i18n_suffix, job.i18n_use_external_ids, - &mut i18n_var_counter, + &mut job.pool, &mut job.emit_errors, ); @@ -476,7 +474,7 @@ fn collect_message<'a>( msg_info: &MessageInfo, file_suffix: &str, use_external_ids: bool, - counter: &mut usize, + pool: &mut ConstantPool<'a>, errors: &mut std::vec::Vec, ) -> (String, ArenaVec<'a, OutputStatement<'a>>) { let mut all_statements = ArenaVec::new_in(&allocator); @@ -496,7 +494,7 @@ fn collect_message<'a>( sub_msg, file_suffix, use_external_ids, - counter, + pool, errors, ); all_statements.extend(sub_statements); @@ -536,15 +534,14 @@ fn collect_message<'a>( // Sort params for consistency params.sort_by(|a, b| a.0.cmp(&b.0)); - // Generate variable names - let i18n_var_name = generate_i18n_var_name(*counter); + // Generate variable names, the Closure one first as in Angular let closure_var_name = generate_closure_var_name( + pool, msg_info.message_id.as_deref().or(msg_info.custom_id.as_deref()), file_suffix, - *counter, use_external_ids, ); - *counter += 1; + let i18n_var_name = generate_i18n_var_name(pool).to_string(); // The meaning and description go into a JSDoc comment, so, as Angular's `tagToString` throws, // neither may end that comment or start another. diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs index b218b7b5c..8817c34b2 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/naming.rs @@ -1180,9 +1180,12 @@ pub fn name_functions_and_variables_for_host(job: &mut HostBindingCompilationJob // Sanitize the base name once let sanitized_base = sanitize_identifier(component_name); - // Generate the function name for the host binding function - let fn_name = format!("{}_{}", component_name, job.fn_suffix); - job.root.fn_name = Some(Ident::from(allocator.alloc_str(&fn_name))); + // Generate the function name for the host binding function, unless the + // caller named it (as Angular's `addNamesToView` keeps a set `fnName`) + if job.root.fn_name.is_none() { + let fn_name = format!("{}_{}", component_name, job.fn_suffix); + job.root.fn_name = Some(Ident::from(allocator.alloc_str(&fn_name))); + } // Name listener handlers in create ops // Host listeners need names like: ComponentName_click_HostBindingHandler diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index 81afb1c1e..04b44f554 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -339,6 +339,28 @@ impl<'a> ArrowFunctionReifier<'a, '_> { } } +/// An `ArrowFunctionReifier` for the operations of a view. +fn arrow_reifier<'a, 'b>( + allocator: &'a oxc_allocator::Allocator, + expressions: &'b ExpressionStore<'a>, + root_xref: XrefId, + ctx: &ReifyContext<'a>, + pool: &'b mut ConstantPool<'a>, + diagnostics: &'b mut Vec, +) -> ArrowFunctionReifier<'a, 'b> { + ArrowFunctionReifier { + allocator, + core_namespace: ctx.core_namespace, + expressions, + root_xref, + mode: ctx.mode, + supports_value_interpolation: ctx.supports_value_interpolation, + supports_dom_property: ctx.supports_dom_property, + pool: RefCell::new(pool), + diagnostics: RefCell::new(diagnostics), + } +} + /// Reify a single view's operations to statements (without modifying the view). fn reify_view_to_stmts<'a>( allocator: &'a oxc_allocator::Allocator, @@ -353,46 +375,25 @@ fn reify_view_to_stmts<'a>( let mut update_stmts = std::vec::Vec::new(); let core_namespace = ctx.core_namespace; - // Hoist arrow functions before the operations that contain them are reified. - { - let reifier = ArrowFunctionReifier { - allocator, - core_namespace, - expressions, - root_xref, - mode: ctx.mode, - supports_value_interpolation: ctx.supports_value_interpolation, - supports_dom_property: ctx.supports_dom_property, - pool: RefCell::new(&mut *pool), - diagnostics: RefCell::new(&mut *diagnostics), - }; - for op in view.create.iter_mut() { - if !matches!( - op, - CreateOp::Listener(_) - | CreateOp::TwoWayListener(_) - | CreateOp::Animation(_) - | CreateOp::AnimationListener(_) - ) { - transform_expressions_in_create_op( - op, - &|expr, _flags| reifier.reify(expr), - VisitorContextFlag::NONE, - ); - } - } - for op in view.update.iter_mut() { - transform_expressions_in_update_op( + // Each operation's arrow functions are hoisted just before it is reified, and the update + // operations' after every create operation, as in Angular's `reify`: a `_forTrack` pooled + // with its `repeaterCreate` comes before the arrow functions of the update operations. + // Use iter_mut() so we can take ownership of expressions that can't be cloned + for op in view.create.iter_mut() { + if !matches!( + op, + CreateOp::Listener(_) + | CreateOp::TwoWayListener(_) + | CreateOp::Animation(_) + | CreateOp::AnimationListener(_) + ) { + let reifier = arrow_reifier(allocator, expressions, root_xref, ctx, pool, diagnostics); + transform_expressions_in_create_op( op, &|expr, _flags| reifier.reify(expr), VisitorContextFlag::NONE, ); } - } - - // Reify create operations - // Use iter_mut() so we can take ownership of expressions that can't be cloned - for op in view.create.iter_mut() { let stmt = reify_create_op( allocator, core_namespace, @@ -408,6 +409,17 @@ fn reify_view_to_stmts<'a>( } } + { + let reifier = arrow_reifier(allocator, expressions, root_xref, ctx, pool, diagnostics); + for op in view.update.iter_mut() { + transform_expressions_in_update_op( + op, + &|expr, _flags| reifier.reify(expr), + VisitorContextFlag::NONE, + ); + } + } + // Reify update operations for op in view.update.iter() { let stmt = reify_update_op( diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 6cde86d9c..d713b7a3c 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -688,6 +688,9 @@ impl Identifiers { /// only the initializer call changes. pub const DEFINE_SERVICE: &'static str = "ɵɵdefineService"; + /// Declare a service (partial compilation of `@Service`). + pub const DECLARE_SERVICE: &'static str = "ɵɵngDeclareService"; + // ======================================================================== // Resolution Instructions // ======================================================================== diff --git a/crates/oxc_angular_compiler/src/service/compiler.rs b/crates/oxc_angular_compiler/src/service/compiler.rs index 0cec906b6..a1221b1f5 100644 --- a/crates/oxc_angular_compiler/src/service/compiler.rs +++ b/crates/oxc_angular_compiler/src/service/compiler.rs @@ -20,7 +20,7 @@ use oxc_str::Ident; use super::metadata::R3ServiceMetadata; use crate::output::ast::{ FunctionExpr, InvokeFunctionExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, - OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, ReturnStatement, + OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, ReturnStatement, clone_callee, }; use crate::r3::Identifiers; @@ -84,7 +84,7 @@ fn create_factory_arrow<'a>( let factory_call = OutputExpression::InvokeFunction(Box::new_in( InvokeFunctionExpr { - fn_expr: Box::new_in(factory.clone_in(allocator), &allocator), + fn_expr: Box::new_in(clone_callee(factory, allocator), &allocator), args: Vec::new_in(&allocator), pure: false, optional: false, diff --git a/crates/oxc_angular_compiler/src/service/definition.rs b/crates/oxc_angular_compiler/src/service/definition.rs index 1273358d6..4780cb39a 100644 --- a/crates/oxc_angular_compiler/src/service/definition.rs +++ b/crates/oxc_angular_compiler/src/service/definition.rs @@ -7,17 +7,22 @@ //! `compiler-cli/src/ngtsc/annotations/src/service.ts:178`, which calls //! `toFactoryMetadata({...meta, deps: []}, FactoryTarget.Service)`. //! - `ɵprov`: the `ɵɵdefineService({...})` initializer. +//! +//! Partial compilation emits `ɵɵngDeclareFactory` (target `Service`) and +//! `ɵɵngDeclareService` instead, as upstream's `compilePartial` does. use oxc_allocator::{Allocator, Vec as OxcVec}; use super::compiler::compile_service; use super::decorator::ServiceMetadata; use super::metadata::R3ServiceMetadata; +use crate::CompilationMode; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3FactoryDeps, R3FactoryMetadata, compile_factory_function, }; use crate::output::ast::OutputExpression; +use crate::partial::{compile_declare_factory_function, compile_declare_service_from_metadata}; /// Both static field initializers for a `@Service` class. pub struct ServiceDefinition<'a> { @@ -32,13 +37,27 @@ pub fn generate_service_definition<'a>( allocator: &'a Allocator, core_namespace: &'a str, metadata: &R3ServiceMetadata<'a>, + compilation_mode: CompilationMode, ) -> ServiceDefinition<'a> { // Generate ɵfac BEFORE ɵprov so namespace-index assignment order matches // upstream's [fac, prov, ...] ordering. - let fac_definition = generate_fac_definition(allocator, core_namespace, metadata); - let prov_result = compile_service(allocator, core_namespace, metadata); - - ServiceDefinition { prov_definition: prov_result.expression, fac_definition } + match compilation_mode { + CompilationMode::Full => { + let fac_definition = generate_fac_definition(allocator, core_namespace, metadata); + let prov_result = compile_service(allocator, core_namespace, metadata); + ServiceDefinition { prov_definition: prov_result.expression, fac_definition } + } + CompilationMode::Partial => { + let fac_definition = compile_declare_factory_function( + allocator, + core_namespace, + &service_factory_metadata(allocator, metadata), + ); + let prov_definition = + compile_declare_service_from_metadata(allocator, core_namespace, metadata); + ServiceDefinition { prov_definition, fac_definition } + } + } } /// Convenience: extract `R3ServiceMetadata` from `ServiceMetadata` and emit. @@ -47,9 +66,10 @@ pub fn generate_service_definition_from_decorator<'a>( core_namespace: &'a str, metadata: &ServiceMetadata<'a>, type_argument_count: u32, + compilation_mode: CompilationMode, ) -> ServiceDefinition<'a> { let r3_metadata = metadata.to_r3_metadata(allocator, type_argument_count); - generate_service_definition(allocator, core_namespace, &r3_metadata) + generate_service_definition(allocator, core_namespace, &r3_metadata, compilation_mode) } /// Emit the ɵfac factory function. `@Service` factories never inject ctor @@ -61,18 +81,24 @@ fn generate_fac_definition<'a>( metadata: &R3ServiceMetadata<'a>, ) -> OutputExpression<'a> { let factory_name = allocator.alloc_str(&format!("{}_Factory", metadata.name)); + let factory_meta = service_factory_metadata(allocator, metadata); + let result = compile_factory_function(allocator, core_namespace, &factory_meta, factory_name); + result.expression +} - let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { +/// The ɵfac metadata of a `@Service`: no ctor deps, target `Service`. +fn service_factory_metadata<'a>( + allocator: &'a Allocator, + metadata: &R3ServiceMetadata<'a>, +) -> R3FactoryMetadata<'a> { + R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { name: metadata.name, type_expr: metadata.r#type.clone_in(allocator), type_decl: metadata.r#type.clone_in(allocator), type_argument_count: metadata.type_argument_count, deps: R3FactoryDeps::Valid(OxcVec::new_in(&allocator)), target: FactoryTarget::Service, - }); - - let result = compile_factory_function(allocator, core_namespace, &factory_meta, factory_name); - result.expression + }) } #[cfg(test)] @@ -98,7 +124,7 @@ mod tests { factory: None, }; - let def = generate_service_definition(&allocator, "i0", &metadata); + let def = generate_service_definition(&allocator, "i0", &metadata, CompilationMode::Full); let emitter = JsEmitter::new(); let fac_js = emitter.emit_expression(&def.fac_definition); let prov_js = emitter.emit_expression(&def.prov_definition); diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 90aa20bd4..007c8e063 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -210,10 +210,10 @@ pub struct HtmlToR3Transform<'a> { /// (e.g., by `ingestControlFlowInsertionPoint` and `ingestStaticAttributes`), both /// can share the same i18n context. i18n_message_instance_counter: u32, - /// The `i18n` attribute value and message instance id of an element whose only child is - /// an ICU. That ICU is the element's message rather than a sub-message, so it reuses the - /// element's message (Angular's I18nMetaVisitor: `currentMessage || meta`). - sole_icu_message: Option<(String, u32)>, + /// The `i18n` attribute value, message instance id and message id of an element whose + /// only child is an ICU. That ICU is the element's message rather than a sub-message, so it + /// reuses the element's message (Angular's I18nMetaVisitor: `currentMessage || meta`). + sole_icu_message: Option<(String, u32, Ident<'a>)>, /// Placeholder names from the enclosing i18n message. message_names: MessagePlaceholderNames, /// Placeholder names from the message of the ICU being visited. @@ -223,7 +223,8 @@ pub struct HtmlToR3Transform<'a> { /// Whether i18n messages get legacy ids (`enableI18nLegacyMessageIdFormat`). legacy_message_ids: bool, /// The children of each element as parsed, before whitespace removal, keyed by the - /// element's start offset. Angular computes legacy ids on the untrimmed template. + /// element's start offset. Angular computes message ids and legacy ids on the untrimmed + /// template. untrimmed_children: FxHashMap]>, /// Whether an ICU visited now is a direct child of an i18n element's message (Angular's /// `currentMessage` in `I18nMetaVisitor.visitExpansion`), not inside a nested element. @@ -291,8 +292,8 @@ impl<'a> HtmlToR3Transform<'a> { } } - /// Gives the transformer the template as parsed, before whitespace removal: legacy message - /// ids are computed on it, as Angular's first `I18nMetaVisitor` pass does. + /// Gives the transformer the template as parsed, before whitespace removal: message ids + /// and legacy ids are computed on it, as Angular's first `I18nMetaVisitor` pass does. #[must_use] pub fn with_untrimmed_nodes(mut self, nodes: &'a [HtmlNode<'a>]) -> Self { fn index<'a>(map: &mut FxHashMap]>, nodes: &'a [HtmlNode<'a>]) { @@ -308,9 +309,7 @@ impl<'a> HtmlToR3Transform<'a> { } } } - if self.legacy_message_ids { - index(&mut self.untrimmed_children, nodes); - } + index(&mut self.untrimmed_children, nodes); self } @@ -327,6 +326,11 @@ impl<'a> HtmlToR3Transform<'a> { ids } + /// Angular's `_setMessageId`: the custom id, else `decimalDigest(message)`. + fn message_id(&self, message: &crate::i18n::ast::Message) -> Ident<'a> { + Ident::from_in(crate::i18n::decimal_digest(message).as_str(), self.allocator) + } + /// Returns the template source file that i18n messages point into. fn i18n_source_file(&self) -> std::sync::Arc { std::sync::Arc::clone(self.i18n_source_file.get_or_init(|| { @@ -582,9 +586,9 @@ impl<'a> HtmlToR3Transform<'a> { // Generate i18n message string from HTML children BEFORE transforming to R3. // This is needed because the message text comes from the original HTML content. // Ported from Angular's I18nMetaVisitor._generateI18nMessage in i18n/meta.ts. - let (i18n_message_string, legacy_ids) = if let Some(attr) = i18n_attr { + let (i18n_message_string, legacy_ids, message_id) = if let Some(attr) = i18n_attr { if element.children.is_empty() { - (String::new(), Vec::new_in(&self.allocator)) + (String::new(), Vec::new_in(&self.allocator), Ident::from("")) } else { // Use I18nMessageFactory to convert children to i18n AST and serialize let factory = I18nMessageFactory::new(false, true); @@ -602,10 +606,13 @@ impl<'a> HtmlToR3Transform<'a> { std::sync::Arc::clone(&source_file), ); collect_placeholder_names(&message.nodes, &mut self.message_names); - // Legacy ids are computed before whitespace removal. - let legacy_ids = match self.untrimmed_children.get(&element.span.start) { - Some(children) if self.legacy_message_ids => { - self.legacy_ids(&factory.create_message( + // The message id and legacy ids are computed before whitespace removal. + let untrimmed = match self.untrimmed_children.get(&element.span.start) { + Some(children) + if children.as_ptr() != element.children.as_ptr() + && (meta.custom_id.is_empty() || self.legacy_message_ids) => + { + Some(factory.create_message( children, Some(element_name), Some(meta.meaning), @@ -615,12 +622,13 @@ impl<'a> HtmlToR3Transform<'a> { source_file, )) } - _ => self.legacy_ids(&message), + _ => None, }; - (message.serialize(), legacy_ids) + let first_pass = untrimmed.as_ref().unwrap_or(&message); + (message.serialize(), self.legacy_ids(first_pass), self.message_id(first_pass)) } } else { - (String::new(), Vec::new_in(&self.allocator)) + (String::new(), Vec::new_in(&self.allocator), Ident::from("")) }; // Now create the i18n metadata with the generated message string @@ -635,6 +643,7 @@ impl<'a> HtmlToR3Transform<'a> { ); if let I18nMeta::Message(message) = &mut meta { message.legacy_ids = legacy_ids; + message.id = message_id; } Some(meta) } else if self.i18n_depth > 0 { @@ -650,7 +659,7 @@ impl<'a> HtmlToR3Transform<'a> { if let (Some(attr), Some(I18nMeta::Message(message))) = (i18n_attr, &i18n_meta) && matches!(element.children.as_slice(), [HtmlNode::Expansion(_)]) { - self.sole_icu_message = Some((attr.value.to_string(), message.instance_id)); + self.sole_icu_message = Some((attr.value.to_string(), message.instance_id, message.id)); } // Increment non_bindable depth if this element has ngNonBindable @@ -1568,16 +1577,19 @@ impl<'a> HtmlToR3Transform<'a> { &crate::i18n::ast::serialize_message_for_id(&icu_message.nodes), "", ); - let (meta_value, instance_id, is_sub_message) = match self.sole_icu_message.take() { - Some((meta_value, instance_id)) => (meta_value, instance_id, false), - None => (String::new(), self.allocate_i18n_message_instance_id(), true), + let (meta_value, instance_id, element_message_id) = match self.sole_icu_message.take() { + Some((meta_value, instance_id, id)) => (meta_value, instance_id, Some(id)), + None => (String::new(), self.allocate_i18n_message_instance_id(), None), }; + let is_sub_message = element_message_id.is_none(); // Whitespace removal leaves an ICU's content as is, so its legacy ids are those of the // first pass. A sole ICU is its element's message and takes the element's meaning. if !is_sub_message { icu_message.meaning = parse_i18n_meta(&meta_value).meaning.to_string(); } let legacy_ids = self.legacy_ids(&icu_message); + // A sole ICU has its element's message id. + let message_id = element_message_id.unwrap_or_else(|| self.message_id(&icu_message)); // Angular builds the ICU's placeholders from the message's placeholders: `VAR_*` become // vars, interpolations become bound text, and element markup becomes plain text. let (vars, others): (std::vec::Vec<_>, std::vec::Vec<_>) = @@ -1607,6 +1619,7 @@ impl<'a> HtmlToR3Transform<'a> { Ident::from_in(associated_message_id.as_str(), self.allocator); } i18n_message.legacy_ids = legacy_ids; + i18n_message.id = message_id; // The pipeline identifies the ICU by a message holding a single IcuPlaceholder. let icu_type_upper = expansion.expansion_type.as_str().to_uppercase(); @@ -3336,7 +3349,10 @@ impl<'a> HtmlToR3Transform<'a> { if let I18nMeta::Message(message) = &mut meta { message.nodes = nodes; legacy_message.meaning = message.meaning.to_string(); + legacy_message.custom_id = message.custom_id.to_string(); + legacy_message.id = legacy_message.custom_id.clone(); message.legacy_ids = self.legacy_ids(&legacy_message); + message.id = self.message_id(&legacy_message); } i18n_attrs_meta.insert(target_attr, meta); } diff --git a/crates/oxc_angular_compiler/src/util/mod.rs b/crates/oxc_angular_compiler/src/util/mod.rs index 5d099dc53..99b0bf5f0 100644 --- a/crates/oxc_angular_compiler/src/util/mod.rs +++ b/crates/oxc_angular_compiler/src/util/mod.rs @@ -34,3 +34,115 @@ pub fn find_constructor<'c, 'a>( _ => None, }) } + +/// A class declaration of a file, with the top-level statement it is in. +pub struct DeclaredClass<'p, 'a> { + /// The declaration. + pub class: &'p oxc_ast::ast::Class<'a>, + /// The class's own declaration (`export` / `export default` included) for + /// a top-level class; for a nested one, the top-level statement holding + /// the function or block it is declared in. + pub top_level: &'p oxc_ast::ast::Statement<'a>, + /// Declared in a function body, block or `namespace` rather than at the + /// top level. ngtsc compiles these too, and hoists their constants to the + /// file top. + pub nested: bool, + /// For a class declared directly in a `namespace` body, that namespace's + /// dotted name (`A.B`). ngtsc only adds statements next to a class in a + /// block or the file, so this one gets no class metadata, debug info or + /// HMR code. It is in the `.d.ts`. + pub namespace: Option, +} + +/// Every class declaration of `program`, top-level and nested alike, in the +/// order ngtsc compiles them: source order, except that the classes declared +/// in a class's members come before it. Classes in a `declare` namespace or +/// module and `declare class`es are left out, as are class expressions: +/// TypeScript rejects decorators on those. +pub fn declared_classes<'p, 'a>( + program: &'p oxc_ast::ast::Program<'a>, +) -> std::vec::Vec> { + use oxc_ast::ast::{Class, Declaration, ExportDefaultDeclarationKind, Statement}; + use oxc_ast_visit::Visit; + + #[derive(Default)] + struct Nested<'a> { + classes: std::vec::Vec<(*const Class<'a>, Option)>, + // The classes declared directly in a `namespace` body, with its + // dotted name. + namespace_members: rustc_hash::FxHashMap<*const Class<'a>, std::string::String>, + // The names of the namespaces being walked, outermost first. + namespaces: std::vec::Vec<&'a str>, + } + impl<'a> Visit<'a> for Nested<'a> { + fn visit_class(&mut self, class: &Class<'a>) { + oxc_ast_visit::walk::walk_class(self, class); + if class.is_declaration() && !class.declare { + let namespace = self.namespace_members.remove(&std::ptr::from_ref(class)); + self.classes.push((class, namespace)); + } + } + fn visit_ts_namespace_declaration( + &mut self, + namespace: &oxc_ast::ast::TSNamespaceDeclaration<'a>, + ) { + if !namespace.declare { + self.namespaces.push(namespace.id.name.as_str()); + oxc_ast_visit::walk::walk_ts_namespace_declaration(self, namespace); + self.namespaces.pop(); + } + } + fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) { + for stmt in &block.body { + let class = match stmt { + Statement::ClassDeclaration(class) => class, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => class, + _ => continue, + }, + _ => continue, + }; + self.namespace_members + .insert(std::ptr::from_ref(&**class), self.namespaces.join(".")); + } + oxc_ast_visit::walk::walk_ts_module_block(self, block); + } + fn visit_ts_external_module_declaration( + &mut self, + _: &oxc_ast::ast::TSExternalModuleDeclaration<'a>, + ) { + } + fn visit_ts_global_declaration(&mut self, _: &oxc_ast::ast::TSGlobalDeclaration<'a>) {} + } + + let mut classes = std::vec::Vec::new(); + for stmt in &program.body { + let class = match stmt { + Statement::ClassDeclaration(class) => Some(class), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class), + _ => None, + }, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => Some(class), + _ => None, + }, + _ => None, + }; + let mut nested = Nested::default(); + match class { + Some(class) => oxc_ast_visit::walk::walk_class(&mut nested, class), + None => nested.visit_statement(stmt), + } + for (class, namespace) in nested.classes { + // SAFETY: the visitor saw `class` while walking `stmt`, which is + // borrowed from `program` for `'p` and not mutated meanwhile. + let class = unsafe { &*class }; + classes.push(DeclaredClass { class, top_level: stmt, nested: true, namespace }); + } + if let Some(class) = class { + classes.push(DeclaredClass { class, top_level: stmt, nested: false, namespace: None }); + } + } + classes +} diff --git a/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs index 9e7885aa1..d1ad9dd1b 100644 --- a/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs @@ -226,6 +226,19 @@ fn hoisted_arrow_in_an_embedded_view_restores_the_view() { ); } +/// Angular's `reify` pools a `@for`'s `_forTrack` with its create operation, before the +/// arrow functions of the update operations, so ngtsc declares `_forTrack0` first. +#[test] +fn track_function_is_pooled_before_hoisted_arrows() { + let code = compile_tpl( + r#"
@for (item of items; track item.id + n) {}"#, + ); + let track = code.find("function_forTrack0").expect(&code); + let arrow = code.find("constarrowFn0=").expect(&code); + assert!(track < arrow, "expected `_forTrack0` before `arrowFn0`, got:\n{code}"); + assert!(code.find("constarrowFn1=").is_some_and(|i| arrow < i), "got:\n{code}"); +} + #[test] fn hoisted_arrow_reads_template_variables_that_are_not_parameters() { assert_hoists( diff --git a/crates/oxc_angular_compiler/tests/const_pool_file_wide_test.rs b/crates/oxc_angular_compiler/tests/const_pool_file_wide_test.rs index 95f4d5df0..d429233cb 100644 --- a/crates/oxc_angular_compiler/tests/const_pool_file_wide_test.rs +++ b/crates/oxc_angular_compiler/tests/const_pool_file_wide_test.rs @@ -6,7 +6,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::{ - CompilationMode, ComponentHmrUpdateInput, TransformOptions, compile_component_hmr_update, link, + CompilationMode, ComponentHmrUpdateInput, HostMetadataInput, TransformOptions, + compile_component_hmr_update, compile_template_to_js_with_options, link, transform_angular_file, }; @@ -163,6 +164,33 @@ export class Cmp { k = true; } assert_eq!(squash(&code).matches("i0.ɵɵpureFunction1(").count(), 2, "{code}"); } +#[test] +fn compile_template_pools_host_constants_first() { + // As a full compile of the component: the host bindings' `{q: x}` and + // `{k: x}` first, the template's `{q: x}` reusing `_c0`, then `{z: x}`. + let allocator = Allocator::default(); + let options = TransformOptions { + host: Some(HostMetadataInput { + properties: vec![ + ("[style]".to_string(), "{q: x}".to_string()), + ("[class]".to_string(), "{k: x}".to_string()), + ], + ..Default::default() + }), + ..Default::default() + }; + let template = r#""#; + let output = compile_template_to_js_with_options(&allocator, template, "A", "a.ts", &options) + .expect("compiles"); + let code = output.code; + assert_eq!(constants(&code), ["_c0", "_c1", "_c2"], "{code}"); + let squashed = squash(&code); + assert!(value(&squashed, "_c0").contains("q:a0"), "{code}"); + assert!(value(&squashed, "_c1").contains("k:a0"), "{code}"); + assert!(value(&squashed, "_c2").contains("z:a0"), "{code}"); + assert!(squashed.contains(r#"ɵɵproperty("title",i0.ɵɵpureFunction1(2,_c0,ctx.x))"#), "{code}"); +} + #[test] fn hmr_update_declares_the_host_constants_its_template_reads() { let source = r#" @@ -271,3 +299,118 @@ export class B { k = 1; } ); assert_eq!(squashed.matches(",_c0,ctx.k)").count(), 2, "{code}"); } + +#[test] +fn i18n_message_names_are_counted_across_the_file() { + // ngtsc names messages with `pool.uniqueName`: `B` goes on from `A`'s + // `i18n_1` and `..._1`, with external ids as without. + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'a-a', template: '

Hello {{w}}

' }) +export class A { w = 1; } + +@Component({ selector: 'b-b', template: '

Bye

' }) +export class B {} +"#; + let names = |use_external_ids: bool| { + let allocator = Allocator::default(); + let options = + TransformOptions { i18n_use_external_ids: use_external_ids, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + // `(i18n_0=MSG_...)`, the Closure branch's assignments. + squash(&result.code) + .split("(i18n_") + .skip(1) + .filter_map(|part| part.split(')').next()) + .filter(|assignment| assignment.contains("=MSG_")) + .map(|assignment| format!("i18n_{assignment}")) + .collect::>() + }; + assert_eq!( + names(true), + [ + "i18n_0=MSG_EXTERNAL_bar$$TEST_TS_0", + "i18n_1=MSG_EXTERNAL_foo$$TEST_TS_1", + "i18n_2=MSG_EXTERNAL_baz$$TEST_TS_2", + ] + ); + assert_eq!( + names(false), + ["i18n_0=MSG_TEST_TS_0", "i18n_1=MSG_TEST_TS_1", "i18n_2=MSG_TEST_TS_2"] + ); +} + +#[test] +fn every_class_declares_its_pool_after_the_imports() { + // ngtsc declares the whole constant pool, child view functions included, + // after the imports and in pool order, not each class's part before it. + let code = transform( + r#" +import { Component } from '@angular/core'; +const helper = 1; +@Component({ selector: 'cmp-a', template: '

@if (k) {}' }) +export class A { k = helper; } +export function f() { return helper; } +@Component({ selector: 'cmp-b', template: '

@if (k) {}' }) +export class B { k = 1; } +"#, + ); + let at = |needle: &str| code.find(needle).expect(&code); + let order = [ + "import * as i0", + "const _c0", + "function A_Conditional_1_Template", + "const _c1", + "function B_Conditional_1_Template", + "const helper", + "export class A", + "export function f", + "export class B", + ]; + assert!(order.windows(2).all(|pair| at(pair[0]) < at(pair[1])), "{code}"); +} + +#[test] +fn pool_goes_before_a_class_that_an_import_follows() { + // ngtsc moves the imports up and declares the pool before the class. The + // class's static fields read `_c1`, so it can't come after the class. + let code = transform( + r#" +import { Component } from '@angular/core'; +@Component({ selector: 'cmp-a', template: '' }) +export class A {} +import { helper } from './other'; +export const h = helper; +"#, + ); + let at = |needle: &str| code.find(needle).expect(&code); + assert!(at("const _c1") < at("export class A"), "{code}"); +} + +#[test] +fn pool_goes_before_the_decorators_a_class_keeps() { + // `@Other()` stays on `Plain`, ahead of `export`: the pool goes before + // it, not between it and its class. + for source in [ + r#" +import { Component } from '@angular/core'; +@Other() export class Plain {} +import { Other } from './other'; +@Component({ selector: 'a-x', template: '@for (i of l; track i.id) {{{i}}}' }) +export class AX { l: any[] = []; } +"#, + r#" +import { Component } from '@angular/core'; +@Other() @Component({ selector: 'a-x', template: '@for (i of l; track i.id) {{{i}}}' }) +export class AX { l: any[] = []; } +import { Other } from './other'; +"#, + ] { + let code = transform(source); + let at = |needle: &str| code.find(needle).expect(&code); + assert!(at("const _forTrack0") < at("@Other()"), "{code}"); + assert!(!squash(&code).contains("@Other()const"), "{code}"); + } +} diff --git a/crates/oxc_angular_compiler/tests/const_pool_long_strings_test.rs b/crates/oxc_angular_compiler/tests/const_pool_long_strings_test.rs index 246366a3f..f54f1a0fd 100644 --- a/crates/oxc_angular_compiler/tests/const_pool_long_strings_test.rs +++ b/crates/oxc_angular_compiler/tests/const_pool_long_strings_test.rs @@ -112,6 +112,31 @@ fn long_styles_are_named_after_the_constants_pooled_before_them() { assert_eq!(code.matches("styles:[_c2]").count(), 1, "{code}"); } +#[test] +fn shared_long_style_is_declared_after_the_sharing_component_constants() { + // ngtsc pools the style while B compiles, after B's template: `_c0`, + // A's view, `_c1`, B's view, then `_c2`, not `_c2` next to A's part. + let source = format!( + "import {{Component}} from '@angular/core';\n\ + @Component({{selector: 'a', template: '

@if (v) {{}}', \ + styles: ['{LONG}'], standalone: true}})\nexport class A {{ v = 1; }}\n\ + @Component({{selector: 'b', template: '

@if (v) {{}}', \ + styles: ['{LONG}'], standalone: true}})\nexport class B {{ v = 1; }}\n", + ); + let code = transform(&source); + let at = |needle: &str| code.find(needle).expect(&code); + let order = [ + "const _c0", + "function A_Conditional_1_Template", + "const _c1", + "function B_Conditional_1_Template", + "const _c2", + "export class A", + ]; + assert!(order.windows(2).all(|pair| at(pair[0]) < at(pair[1])), "{code}"); + assert_eq!(squash(&code).matches("styles:[_c2]").count(), 2, "{code}"); +} + #[test] fn query_predicate_of_two_components_is_one_constant() { let source = "import {Component, ViewChild} from '@angular/core'; 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 d72508117..c772828cd 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -295,6 +295,265 @@ export class Parent {} assert!(code.contains("import { EagerCmp }"), "{code}"); } +fn shared_other_cmp(dir: &TempDir) { + other_cmp(dir); + create_test_file( + dir.path(), + "app/shared.ts", + r" +import { OtherCmp } from './other'; + +export const SHARED = [OtherCmp]; +", + ); +} + +#[test] +fn entry_next_to_a_spread_entry_is_lazy_loaded() { + // ngc's `registerDeferrableCandidate` skips the spread, and the classes it + // brings in stay eager. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + shared_other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { SHARED } from './shared'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, ...SHARED], + template: '@defer { } ', +}) +export class Parent {} +", + ); + let code = &result.code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!(compact(code).contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), "{code}"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[...SHARED])"), "{code}"); + assert!(!code.contains("import { LazyCmp }"), "{code}"); +} + +#[test] +fn entry_deferred_by_two_components_one_with_a_spread_entry_is_lazy_loaded() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + shared_other_cmp(&dir); + let result = transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { SHARED } from './shared'; + +@Component({ selector: 'app-a', imports: [LazyCmp], template: '@defer { }' }) +export class ParentA {} + +@Component({ + selector: 'app-b', + imports: [LazyCmp, ...SHARED], + template: '@defer { } ', +}) +export class ParentB {} +", + ); + let code = &result.code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!( + compact(code).contains("setClassMetadataAsync(ParentA,()=>[import(\"./lazy\")") + && compact(code).contains("setClassMetadataAsync(ParentB,()=>[import(\"./lazy\")"), + "{code}" + ); + assert!(!code.contains("import { LazyCmp }"), "{code}"); +} + +#[test] +fn entry_next_to_a_nested_array_entry_is_lazy_loaded() { + // Like a spread, ngc skips the nested array, and its classes stay eager. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let source = r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, [OtherCmp]], + template: '@defer { } ', +}) +export class Parent {} +"; + let code = &transform(&dir, source).code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!(compact(code).contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), "{code}"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[[OtherCmp]])"), "{code}"); + assert!(!code.contains("import { LazyCmp }"), "{code}"); + + let code = &transform_with(&dir, source, &partial_options()).code; + assert!( + compact(code) + .contains("deferBlockDependencies:[()=>[import(\"./lazy\").then((m)=>m.LazyCmp)]]"), + "{code}" + ); + assert!(!code.contains("import { LazyCmp }"), "{code}"); +} + +#[test] +fn entry_next_to_a_namespace_member_entry_is_lazy_loaded() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let code = &transform( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import * as other from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, other.OtherCmp], + template: '@defer { } ', +}) +export class Parent {} +", + ) + .code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[other.OtherCmp])"), "{code}"); + assert!(!code.contains("import { LazyCmp }"), "{code}"); +} + +#[test] +fn deps_fns_list_classes_of_entries_that_are_not_identifiers() { + // ngc's scope flattens nested arrays and spreads and resolves `ns.Cmp`, so + // a block's function lists what they bring in, eagerly, once per class: + // `other.LazyCmp` is `LazyCmp`, which its identifier entry lazy-loads. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + create_test_file( + dir.path(), + "app/third.ts", + r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-third', template: 'third' }) +export class ThirdCmp {} + +export { LazyCmp } from './lazy'; +", + ); + let source = r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; +import * as third from './third'; + +const SHARED = [third.ThirdCmp]; + +@Component({ + selector: 'app-parent', + imports: [third.LazyCmp, LazyCmp, [OtherCmp], ...SHARED], + template: '@defer { }', +}) +export class Parent {} +"; + let code = &transform(&dir, source).code; + assert!( + compact(code).contains( + "constParent_Defer_1_DepsFn=()=>[import(\"./lazy\").then((m)=>m.LazyCmp),OtherCmp,third.ThirdCmp];" + ), + "{code}" + ); + assert!(!code.contains("import { LazyCmp }"), "{code}"); + + let code = &transform_with(&dir, source, &partial_options()).code; + assert!( + compact(code).contains( + "deferBlockDependencies:[()=>[import(\"./lazy\").then((m)=>m.LazyCmp),OtherCmp,third.ThirdCmp]]" + ), + "{code}" + ); +} + +#[test] +fn partial_dependencies_list_a_class_two_entries_name_once() { + // `more.LazyCmp` is `LazyCmp`: ngc's scope lists it once, and a component + // matched twice on one element is an error at runtime. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + create_test_file( + dir.path(), + "app/more.ts", + "export { LazyCmp } from './lazy';\nexport { OtherCmp } from './other';\n", + ); + let source = r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import * as more from './more'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, more.LazyCmp, more.OtherCmp], + template: '', +}) +export class Parent {} +"; + let code = &transform_with(&dir, source, &partial_options()).code; + assert!( + compact(code).contains( + "dependencies:[{kind:\"directive\",type:LazyCmp,selector:\"*\"},{kind:\"directive\",type:more.OtherCmp,selector:\"*\"}]" + ), + "{code}" + ); +} + +#[test] +fn partial_dependencies_leave_out_a_lazy_loaded_class_another_entry_names() { + // `@defer` lazy-loads `LazyCmp`, which `more.LazyCmp` names too: ngc's + // `dependencies` leaves it out, in whichever order the entries come. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + create_test_file( + dir.path(), + "app/more.ts", + "export { LazyCmp } from './lazy';\nexport { OtherCmp } from './other';\n", + ); + for imports in + ["[LazyCmp, more.LazyCmp, more.OtherCmp]", "[more.LazyCmp, LazyCmp, more.OtherCmp]"] + { + let source = format!( + r" +import {{ Component }} from '@angular/core'; +import {{ LazyCmp }} from './lazy'; +import * as more from './more'; + +@Component({{ + selector: 'app-parent', + imports: {imports}, + template: ' @defer {{ }}', +}}) +export class Parent {{}} +" + ); + let code = &transform_with(&dir, &source, &partial_options()).code; + assert!( + compact(code) + .contains("dependencies:[{kind:\"directive\",type:more.OtherCmp,selector:\"*\"}]"), + "{code}" + ); + assert_lazy(code, "./lazy", "LazyCmp"); + } +} + #[test] fn forward_ref_not_from_angular_core_stays_eager() { let dir = TempDir::new().unwrap(); @@ -431,33 +690,64 @@ export class Parent {} } #[test] -fn pipe_stays_eager() { +fn pipe_used_only_inside_defer_is_lazy_loaded() { + // ngc matches a standalone pipe by its name: used only inside `@defer`, + // it is lazy-loaded like a component, and listed after the directives. + // A `when` condition is part of the enclosing template. let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); create_test_file( dir.path(), "app/shout.ts", - r#" + r" import { Pipe } from '@angular/core'; @Pipe({ name: 'shout' }) export class ShoutPipe { transform(v: string) { return v; } } -"#, +", ); - let result = transform( - &dir, - r#" + create_test_file( + dir.path(), + "app/whisper.ts", + r" +import { Pipe } from '@angular/core'; + +@Pipe({ name: 'whisper' }) +export class WhisperPipe { transform(v: string) { return v; } } +", + ); + let source = r" import { Component } from '@angular/core'; import { ShoutPipe } from './shout'; +import { WhisperPipe } from './whisper'; +import { LazyCmp } from './lazy'; @Component({ selector: 'app-parent', - imports: [ShoutPipe], - template: '@defer { {{ name | shout }} }', + imports: [ShoutPipe, LazyCmp, WhisperPipe], + template: '@defer { {{ name | shout }} } @defer (when name | whisper) { {{ name | whisper }} }', }) export class Parent { name = ''; } -"#, +"; + let code = &transform(&dir, source).code; + assert!( + compact(code).contains( + "constParent_Defer_1_DepsFn=()=>[import(\"./lazy\").then((m)=>m.LazyCmp),import(\"./shout\").then((m)=>m.ShoutPipe)];" + ), + "{code}" + ); + assert!(compact(code).contains("constParent_Defer_4_DepsFn=()=>[WhisperPipe];"), "{code}"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[WhisperPipe])"), "{code}"); + assert!(code.contains("import { WhisperPipe } from './whisper'"), "{code}"); + assert!(!code.contains("import { ShoutPipe }"), "{code}"); + + let code = &transform_with(&dir, source, &partial_options()).code; + assert!( + compact(code).contains( + "deferBlockDependencies:[()=>[import(\"./lazy\").then((m)=>m.LazyCmp),import(\"./shout\").then((m)=>m.ShoutPipe)],()=>[WhisperPipe]]" + ), + "{code}" ); - assert_eager(&result.code, "./shout"); } #[test] @@ -1548,6 +1838,95 @@ export class C {} assert!(compact.contains("ɵɵdefer(2,1,C_Defer_2_DepsFn);"), "{code}"); } +#[test] +fn deps_fns_reading_a_const_imports_array_are_shared_by_components() { + // ngc resolves `imports: IMPORTS` through the `const`, and reads each class + // through the identifier in its array, which both components share: their + // equivalent functions are one. Classes outside an `imports` array literal + // are never lazy-loaded. + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "app/deps.ts", + r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-dep', template: 'dep' }) +export class Dep {} + +@Component({ selector: 'app-dep2', template: 'dep2' }) +export class Dep2 {} +", + ); + let source = r" +import { Component } from '@angular/core'; +import { Dep, Dep2 } from './deps'; + +const IMPORTS = [Dep, Dep2]; + +@Component({ selector: 'app-a', imports: IMPORTS, template: ' @defer { }' }) +export class A {} + +@Component({ selector: 'app-b', imports: IMPORTS, template: '@defer { }' }) +export class B {} + +@Component({ selector: 'app-c', imports: IMPORTS, template: '@defer { }' }) +export class C {} +"; + let code = &transform(&dir, source).code; + let compact = compact(code); + assert!(compact.contains("constA_Defer_2_DepsFn=()=>[Dep2];"), "{code}"); + assert!(compact.contains("ɵɵdefer(2,1,A_Defer_2_DepsFn);"), "{code}"); + assert!(compact.contains("constB_Defer_1_DepsFn=()=>[Dep,Dep2];"), "{code}"); + assert!(!compact.contains("C_Defer_1_DepsFn"), "{code}"); + assert_eq!(compact.matches("ɵɵdefer(1,0,B_Defer_1_DepsFn);").count(), 2, "{code}"); + assert!(code.contains("import { Dep, Dep2 }"), "{code}"); + assert_eager(code, "./deps"); + + let code = &transform_with(&dir, source, &partial_options()).code; + let partial = self::compact(code); + assert!(partial.contains("deferBlockDependencies:[()=>[Dep2]]"), "{code}"); + assert_eq!(partial.matches("deferBlockDependencies:[()=>[Dep,Dep2]]").count(), 2, "{code}"); +} + +#[test] +fn deps_fns_read_an_imports_array_through_let_an_alias_and_as_const() { + // ngc's partial evaluator reads any top-level variable, through `as const` + // and through a variable naming another one. + let dir = TempDir::new().unwrap(); + for declaration in [ + "let IMPORTS = [Dep, Dep2];", + "const BASE = [Dep, Dep2];\nconst IMPORTS = BASE;", + "const IMPORTS = [Dep, Dep2] as const;", + ] { + let source = format!( + r" +import {{ Component }} from '@angular/core'; + +@Component({{ selector: 'app-dep', template: 'dep' }}) +export class Dep {{}} + +@Component({{ selector: 'app-dep2', template: 'dep2' }}) +export class Dep2 {{}} + +{declaration} + +@Component({{ selector: 'app-a', imports: IMPORTS, template: ' @defer {{ }}' }}) +export class A {{}} +" + ); + let code = &transform(&dir, &source).code; + let compact = compact(code); + assert!(compact.contains("constA_Defer_2_DepsFn=()=>[Dep2];"), "{code}"); + assert!(compact.contains("ɵɵdefer(2,1,A_Defer_2_DepsFn);"), "{code}"); + + let code = &transform_with(&dir, &source, &partial_options()).code; + let partial = self::compact(code); + assert!(partial.contains("deferBlockDependencies:[()=>[Dep2]]"), "{code}"); + assert!(!partial.contains("type:IMPORTS"), "{code}"); + } +} + // --- ngc's `DeferredSymbolTracker` over the whole file: the `imports` // entries of every component that defers a class don't keep its import --- @@ -1690,3 +2069,236 @@ export class Parent {} "{code}" ); } + +#[test] +fn deps_fns_are_declared_in_pool_order_with_the_constants() { + // ngtsc's pool keeps one list of statements: the DepsFn is pooled before + // the pure function and the track function, and declared first. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let source = r#" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +@Component({ + selector: 'app-a', + imports: [LazyCmp], + template: '@defer {}@for (x of xs; track x.id) {}', +}) +export class A { k = 1; xs: any[] = []; } +"#; + let code = transform(&dir, source).code; + let declared: Vec<&str> = code + .lines() + .filter_map(|line| line.strip_prefix("const ").or_else(|| line.strip_prefix("function "))) + .filter_map(|rest| rest.split([' ', '=', '(']).next()) + .collect(); + assert_eq!( + declared, + ["A_Defer_1_DepsFn", "_c0", "_forTrack0", "A_Defer_0_Template", "A_For_5_Template"], + "{code}" + ); +} + +fn partial_options() -> TransformOptions { + TransformOptions { + compilation_mode: oxc_angular_compiler::CompilationMode::Partial, + ..options() + } +} + +#[test] +fn partial_declaration_lazy_loads_a_component_used_only_inside_defer() { + // Like ngc's `compilePartial`: the blocks are resolved as in a full + // compilation, and the declaration carries their dependency functions. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform_with( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp], + template: '@defer { }', +}) +export class Parent {} +", + &partial_options(), + ); + let code = &result.code; + let compact = compact(code); + + assert!( + compact.contains("deferBlockDependencies:[()=>[import(\"./lazy\").then((m)=>m.LazyCmp)]]"), + "The block's resolver must be in the declaration. Output:\n{code}" + ); + assert!(!compact.contains("dependencies:[{"), "Output:\n{code}"); + assert!(!code.contains("import { LazyCmp }"), "Output:\n{code}"); + assert!( + compact.contains( + "ɵɵngDeclareClassMetadataAsync({minVersion:\"18.0.0\",version:\"0.0.0-PLACEHOLDER\",ngImport:i0,type:Parent,resolveDeferredDeps:()=>[import(\"./lazy\").then((m)=>m.LazyCmp)],resolveMetadata:(LazyCmp)=>" + ), + "Output:\n{code}" + ); + + // The linker wires the resolver into `ɵɵdefer`, as Angular's does. + let allocator = Allocator::default(); + let linked = oxc_angular_compiler::linker::link(&allocator, code, "parent.component.js"); + let linked = linked.code.chars().filter(|c| !c.is_whitespace()).collect::(); + assert!( + linked.contains("constParent_Defer_1_DepsFn=()=>[import(\"./lazy\")") + && linked.contains("ɵɵdefer(1,0,Parent_Defer_1_DepsFn)"), + "Linked:\n{linked}" + ); +} + +#[test] +fn partial_declaration_lists_a_null_resolver_for_a_block_without_dependencies() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + other_cmp(&dir); + let result = transform_with( + &dir, + r" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { OtherCmp } from './other'; + +@Component({ + selector: 'app-parent', + imports: [LazyCmp, OtherCmp], + template: ' @defer { nothing } @defer { }', +}) +export class Parent {} +", + &partial_options(), + ); + let code = &result.code; + let compact = compact(code); + + assert!( + compact.contains( + "deferBlockDependencies:[null,()=>[import(\"./lazy\").then((m)=>m.LazyCmp),OtherCmp]]" + ), + "Output:\n{code}" + ); + assert!(compact.contains("dependencies:[{kind:\"directive\",type:OtherCmp"), "Output:\n{code}"); +} + +// --- Classes nested in a function count with the file's other components --- + +#[test] +fn import_deferred_by_a_nested_and_a_top_level_component_is_lazy_in_both() { + // ngc's `DeferredSymbolTracker` sees every component of the file, nested + // ones too: both lazy-load `LazyCmp`, and its static import goes. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r#" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +export function make() { + @Component({ selector: 'app-inner', imports: [LazyCmp], template: '@defer { }' }) + class Inner {} + return Inner; +} + +@Component({ selector: 'app-top', imports: [LazyCmp], template: '@defer { }' }) +export class Top {} +"#, + ); + let code = &result.code; + let compact = compact(code); + assert!(!code.contains("import { LazyCmp }"), "{code}"); + assert!(compact.contains("setClassMetadataAsync(Inner,()=>[import(\"./lazy\")"), "{code}"); + assert!(compact.contains("setClassMetadataAsync(Top,()=>[import(\"./lazy\")"), "{code}"); + assert!(!compact.contains("=()=>[LazyCmp]"), "{code}"); +} + +#[test] +fn import_deferred_by_two_nested_components_is_lazy_loaded() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r#" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +export function one() { + @Component({ selector: 'app-one', imports: [LazyCmp], template: '@defer { }' }) + class One {} + return One; +} + +export function two() { + @Component({ selector: 'app-two', imports: [LazyCmp], template: '@defer { }' }) + class Two {} + return Two; +} +"#, + ); + let code = &result.code; + let compact = compact(code); + assert!(!code.contains("import { LazyCmp }"), "{code}"); + assert!(compact.contains("setClassMetadataAsync(One,()=>[import(\"./lazy\")"), "{code}"); + assert!(compact.contains("setClassMetadataAsync(Two,()=>[import(\"./lazy\")"), "{code}"); +} + +#[test] +fn specifiers_deferred_by_a_top_level_and_a_nested_component_remove_the_declaration() { + let dir = TempDir::new().unwrap(); + two_cmps(&dir); + let result = transform( + &dir, + r#" +import { Component } from '@angular/core'; +import { LazyCmp, EagerCmp } from './cmps'; + +@Component({ selector: 'app-top', imports: [LazyCmp], template: '@defer { }' }) +export class Top {} + +export function make() { + @Component({ selector: 'app-inner', imports: [EagerCmp], template: '@defer { }' }) + class Inner {} + return Inner; +} +"#, + ); + let code = &result.code; + assert_lazy(code, "./cmps", "LazyCmp"); + assert_lazy(code, "./cmps", "EagerCmp"); + assert!(!code.contains("from './cmps'") && !code.contains("from \"./cmps\""), "{code}"); +} + +#[test] +fn nested_component_keeping_its_decorators_keeps_the_import() { + // `Inner` reads a binding of `make`, so it keeps its decorators and JIT + // compiles: its `imports` entry stays a reference to `LazyCmp`. + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r#" +import { Component } from '@angular/core'; +import { LazyCmp } from './lazy'; + +export function make(selector: string) { + @Component({ selector, imports: [LazyCmp], template: '@defer { }' }) + class Inner {} + return Inner; +} + +@Component({ selector: 'app-top', imports: [LazyCmp], template: '@defer { }' }) +export class Top {} +"#, + ); + let code = &result.code; + assert_eager(code, "./lazy"); + assert!(code.contains("import { LazyCmp }"), "{code}"); +} diff --git a/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs b/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs index 0a95bbe0e..484e78585 100644 --- a/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs +++ b/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs @@ -11,7 +11,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::{ - ComponentHmrFingerprint, TransformOptions, component_hmr_fingerprint, transform_angular_file, + ComponentHmrFingerprint, ComponentHmrUpdateInput, TransformOptions, + compile_component_hmr_update, component_hmr_fingerprint, transform_angular_file, }; const IMPORT: &str = "import { Component } from '@angular/core';\n"; @@ -171,10 +172,76 @@ fn transform_keeps_a_jit_components_template() { ); } +/// A component declared in a function is compiled with HMR too, so an edit to +/// its template is an HMR update. Two classes of one name compile without +/// HMR, so an edit to either one's template reloads. The transform and +/// `compile_component_hmr_update` give the same fingerprint. +#[test] +fn reads_a_nested_component() { + let source = format!( + "{IMPORT}export function make() {{ + @Component({{ selector: 'a', template: '', styles: ['.a{{}}'] }}) + class A {{ x = 1 }} + return A; +}} +function one() {{ @Component({{ template: '' }}) class B {{}} return B; }} +function two() {{ @Component({{ template: '' }}) class B {{}} return B; }} +" + ); + let allocator = Allocator::default(); + let options = TransformOptions { hmr: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "test.ts", &source, Some(&options), None); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert_eq!(result.template_updates.keys().collect::>(), ["test.ts@A"]); + let fingerprint = result.hmr_fingerprint.unwrap(); + assert_eq!(fingerprint_raw(&source, &["A"]).fingerprint, fingerprint); + assert_eq!(fingerprint_raw(&source, &["A", "B"]).fingerprint, fingerprint); + let input = ComponentHmrUpdateInput { + class_name: "A", + source_fingerprint: None, + compiled_class_names: Some(&["A"]), + }; + let update = + compile_component_hmr_update(&allocator, "test.ts", &source, &input, Some(&options), None); + assert_eq!(update.fingerprint.as_deref(), Some(fingerprint.as_str())); + + let of = |edited: String| fingerprint_raw(&edited, &["A"]).fingerprint; + assert_eq!(of(source.replace("", "x")), fingerprint); + assert_eq!(of(source.replace(".a{}", ".a{color:red}")), fingerprint); + assert_ne!(of(source.replace("x = 1", "x = 2")), fingerprint); + assert_ne!(of(source.replace("", "x")), fingerprint); +} + // ----------------------------------------------------------------- // Which decorators are read // ----------------------------------------------------------------- +/// A `namespace` member compiles without HMR, so an edit to its template +/// reloads, while a top-level component of its name keeps HMR. +#[test] +fn leaves_a_namespace_member_out() { + let source = format!( + "{IMPORT}export namespace NS {{ + @Component({{ selector: 'n', template: '' }}) + export class App {{}} +}} +@Component({{ selector: 'app', template: '' }}) +export class App {{}} +" + ); + let allocator = Allocator::default(); + let options = TransformOptions { hmr: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "test.ts", &source, Some(&options), None); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert_eq!(result.template_updates.keys().collect::>(), ["test.ts@App"]); + let fingerprint = result.hmr_fingerprint.unwrap(); + assert_eq!(fingerprint_raw(&source, &["App"]).fingerprint, fingerprint); + + let of = |edited: String| fingerprint_raw(&edited, &["App"]).fingerprint; + assert_eq!(of(source.replace("", "x")), fingerprint); + assert_ne!(of(source.replace("", "x")), fingerprint); +} + /// The decorator is Angular's by its `@angular/core` import, under any alias /// or through a namespace, like the transform finds it. Another module's /// `@Component` or a lookalike spelling is not read. diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 096d8530a..87fda0f68 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -704,6 +704,39 @@ export class Parent {} ); } +/// A component declared in the file needs no cross-file analysis for its +/// selector: each `@defer` block that uses it gets its own dependency +/// function, as in ngc's full compilation, and the second component reuses +/// the first one's, which ngc's single pool finds equivalent. +#[test] +fn test_defer_block_using_a_same_file_component_gets_its_own_resolver() { + let allocator = Allocator::default(); + let source = r" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-dep', template: 'dep' }) +export class Dep {} + +@Component({ selector: 'app-a', imports: [Dep], template: ' @defer { }' }) +export class A {} + +@Component({ selector: 'app-b', imports: [Dep], template: '@defer { }' }) +export class B {} +"; + + let result = transform_angular_file(&allocator, "parent.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + let collapsed: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(collapsed.contains("constA_Defer_2_DepsFn=()=>[Dep];"), "{code}"); + assert!(collapsed.contains("ɵɵdefer(2,1,A_Defer_2_DepsFn);"), "{code}"); + assert!(collapsed.contains("ɵɵdefer(1,0,A_Defer_2_DepsFn);"), "{code}"); + assert!(collapsed.contains("ɵɵgetComponentDepsFactory(A,[Dep])"), "{code}"); + // Used only inside `@defer`, the class leaves `dependencies`. + assert!(!collapsed.contains("ɵɵgetComponentDepsFactory(B"), "{code}"); +} + /// Aliased imports must use the original exported name in `m.X`, not the /// local alias. #[test] @@ -5864,6 +5897,33 @@ fn test_i18n_meta_is_trimmed_once() { assert_contains(&js, r#"$localize `:m | d @@ custom:Custom`"#); } +/// With external ids, a message without a custom id is named after its computed id +/// (`decimalDigest`, with the meaning), and so are ICU sub-messages. The id of an element +/// message is computed before whitespace removal, and a sole ICU takes it. Angular 22.2.2 +/// gives these names, in this order (MSG_3 is the nested select, MSG_4 the plural's element): +#[test] +fn test_i18n_external_ids_use_computed_message_id() { + let js = compile_i18n_component( + "
Hello {{name}}!
Hi x\ + \"Logo\"\ +
\n {count, plural, =1 {one} other {{gender, select, male {m} other {o}}}}\n
\ +
before {count, plural, =1 {one} other {many}} after
\ + {gender, select, male {M} other {O}}", + ); + for name in [ + "MSG_EXTERNAL_96409403850032568$$REPRO_TS_0 =", + "MSG_EXTERNAL_4848371593517801531$$REPRO_TS_1 =", + "MSG_EXTERNAL_7210328206936269780$$REPRO_TS_2 =", + "MSG_EXTERNAL_2330724444582433569$$REPRO_TS_3 =", + "MSG_EXTERNAL_2793568635912092133$$REPRO_TS_4 =", + "MSG_EXTERNAL_2403750739669008444$$REPRO_TS_5 =", + "MSG_EXTERNAL_6754785010889443455$$REPRO_TS_6 =", + "MSG_EXTERNAL_4388111603494198272$$REPRO_TS_7 =", + ] { + assert_contains(&js, name); + } +} + /// Compiles `template` with `enableI18nLegacyMessageIdFormat` set to `legacy_ids`. fn compile_with_legacy_ids(template: &str, legacy_ids: bool) -> String { let allocator = Allocator::default(); @@ -7698,6 +7758,60 @@ export class MixedQueryComponent { ); } +/// Angular's `getQueryPredicate` splits a string predicate on `,` and trims +/// each part, keeping the empty ones: ngtsc 22.2.3 gives `["x", ""]` for +/// `@ViewChild('x, ')`, for decorator and signal queries alike. +#[test] +fn test_query_predicate_keeps_empty_parts() { + let allocator = Allocator::default(); + let source = r" +import { Component, ContentChild, ViewChild, ViewChildren, viewChild, contentChildren } from '@angular/core'; + +@Component({ selector: 'app-a', template: '', standalone: true }) +export class A { + a = viewChild('x, '); + b = contentChildren('x,,y'); + @ViewChildren(', y') c: any; + @ContentChild(', ') d: any; +} +"; + + let result = transform_angular_file(&allocator, "a.ts", source, None, None); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + code.contains( + r#"const_c0=["x","","y"];const_c1=["",""];const_c2=["x",""];const_c3=["","y"];"# + ), + "{}", + result.code + ); +} + +/// The predicate parts are trimmed with `String.prototype.trim`, which strips +/// U+FEFF but not U+0085: ngtsc 22.2.3 gives `["x"]` for `@ViewChild('\uFEFFx')` +/// and `["y\u0085"]` for `@ViewChild('y\u0085')`. +#[test] +fn test_query_predicate_trims_like_js() { + let allocator = Allocator::default(); + let source = r" +import { Component, ViewChild } from '@angular/core'; + +@Component({ selector: 'app-a', template: '', standalone: true }) +export class A { + @ViewChild('\uFEFFx') q: any; + @ViewChild('y\u0085') r: any; +} +"; + + let result = transform_angular_file(&allocator, "a.ts", source, None, None); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + assert!(result.code.contains("const _c0 = [\"x\"];"), "{}", result.code); + assert!(result.code.contains("const _c1 = [\"y\u{85}\"];"), "{}", result.code); +} + /// Compatibility guard. Two contracts at once: /// /// 1. **`None` = assume latest.** Crates-wide convention: unset @@ -16164,6 +16278,138 @@ export class I3 {} } } +#[test] +fn test_use_class_call_is_parenthesized_when_instantiated() { + // A `useClass` with deps is instantiated as `new useClass(...deps)`. + // ngtsc wraps a call or an optional chain in the callee: + // `new makeClass()(dep)` would construct `makeClass` itself, and + // `new Maybe?.cls(dep)` is a SyntaxError. + let code = compile_to_valid_js( + r" +import { Injectable, InjectionToken } from '@angular/core'; +export const DEP = new InjectionToken('DEP'); +export function makeClass() { return class { constructor(d) { this.d = d; } }; } +export const Maybe = undefined; +@Injectable({ providedIn: 'root', useClass: makeClass(), deps: [DEP] }) +export class E1 {} +@Injectable({ providedIn: 'root', useClass: makeClass().inner, deps: [DEP] }) +export class E2 {} +@Injectable({ providedIn: 'root', useClass: Maybe?.cls, deps: [DEP] }) +export class E3 {} +@Injectable({ providedIn: 'root', useClass: Maybe.cls, deps: [DEP] }) +export class E4 {} +", + ); + for expected in [ + "new (makeClass())(i0.ɵɵinject(DEP))", + "new (makeClass().inner)(i0.ɵɵinject(DEP))", + "new (Maybe?.cls)(i0.ɵɵinject(DEP))", + "new Maybe.cls(i0.ɵɵinject(DEP))", + ] { + assert!(code.contains(expected), "Missing `{expected}` in:\n{code}"); + } +} + +#[test] +fn test_optional_chain_factory_is_parenthesized_when_called() { + // ngtsc wraps an optional-chain factory before calling it, so + // `(Maybe?.make)()` throws when `Maybe` is nullish. A bare + // `Maybe?.make()` would short-circuit to `undefined` instead. + let code = compile_to_valid_js( + r" +import { Injectable, InjectionToken, Service } from '@angular/core'; +export const DEP = new InjectionToken('DEP'); +export const Maybe = undefined; +@Injectable({ providedIn: 'root', useFactory: Maybe?.make }) +export class F1 {} +@Service({ factory: Maybe?.make }) +export class F2 {} +@Injectable({ providedIn: 'root', useFactory: Maybe?.make, deps: [DEP] }) +export class F3 {} +@Injectable({ providedIn: 'root', useFactory: Maybe?.a.b?.() }) +export class F4 {} +@Injectable({ providedIn: 'root', useFactory: (Maybe?.a).b }) +export class F5 {} +@Injectable({ providedIn: 'root', useFactory: Maybe.make }) +export class F6 {} +", + ); + for expected in [ + "factory:() =>(Maybe?.make)(),", + "return (Maybe?.make)();", + "(Maybe?.make)(i0.ɵɵinject(DEP))", + "factory:() =>(Maybe?.a.b?.())(),", + "factory:() =>(Maybe?.a).b(),", + "factory:() =>Maybe.make(),", + ] { + assert!(code.contains(expected), "Missing `{expected}` in:\n{code}"); + } +} + +#[test] +fn test_optional_chain_use_class_is_parenthesized_before_fac() { + // ngtsc reads `ɵfac` off `(Maybe?.cls)`, which throws when `Maybe` is + // nullish. A bare `Maybe?.cls.ɵfac(...)` would short-circuit instead. + let code = compile_to_valid_js( + r" +import { Injectable } from '@angular/core'; +export const Maybe = undefined; +@Injectable({ providedIn: 'root', useClass: Maybe?.cls }) +export class C1 {} +@Injectable({ providedIn: 'root', useClass: Maybe.cls }) +export class C2 {} +", + ); + for expected in [ + "factory:(__ngFactoryType__) =>(Maybe?.cls).ɵfac(__ngFactoryType__),", + "factory:(__ngFactoryType__) =>Maybe.cls.ɵfac(__ngFactoryType__),", + ] { + assert!(code.contains(expected), "Missing `{expected}` in:\n{code}"); + } +} + +#[test] +fn test_use_class_with_empty_deps_is_instantiated() { + // `deps: []` is still deps: ngtsc builds `new useClass()` instead of + // delegating to `useClass.ɵfac`, which a plain class does not have. + let code = compile_to_valid_js( + r" +import { Injectable } from '@angular/core'; +export const Holder = { cls: () => class {} }; +@Injectable({ providedIn: 'root', useClass: Holder.cls(), deps: [] }) +export class F1 {} +@Injectable({ providedIn: 'root', useClass: Holder.cls, deps: [] }) +export class F2 {} +", + ); + for expected in [ + "(__ngConditionalFactory__ = new (Holder.cls())());", + "(__ngConditionalFactory__ = new Holder.cls());", + ] { + assert!(code.contains(expected), "Missing `{expected}` in:\n{code}"); + } + assert!(!code.contains("ɵfac(__ngFactoryType__)"), "Unexpected delegation in:\n{code}"); +} + +#[test] +fn test_use_factory_with_empty_deps_builds_conditional_factory() { + // `deps: []` is still deps: ngtsc builds the delegated conditional + // factory, as for useClass, instead of `() => make()`. + let code = compile_to_valid_js( + r" +import { Injectable } from '@angular/core'; +export function make() { return {}; } +@Injectable({ providedIn: 'root', useFactory: make, deps: [] }) +export class F1 {} +", + ); + assert!( + code.contains("(__ngConditionalFactory__ = make());"), + "Missing conditional factory in:\n{code}" + ); + assert!(!code.contains("() => make()"), "Unexpected arrow wrapper in:\n{code}"); +} + #[test] fn test_aot_service_decorator_version_gated() { // Targeting Angular < 22 should surface a diagnostic and leave the @@ -16723,6 +16969,62 @@ fn test_component_class_metadata_precedes_debug_info() { ); } +#[test] +fn test_component_debug_info_line_is_the_class_keyword() { + // ngtsc takes the line of `clazz.name.pos`, the end of the `class` + // keyword, not of a decorator above it. Lines checked against ngtsc. + let allocator = Allocator::default(); + let source = "import { Component } from '@angular/core'; + +@Component({ selector: 'a', template: '' }) +class A {} + +@Component({ + selector: 'b', + template: '', +}) +export class B {} + +@Component({ selector: 'c', template: '' }) export class C {} + +@Component({ selector: 'd', template: '' }) +export +abstract class /* x + */ + D {} + +export function f() { + @Component({ selector: 'e', template: '' }) + class E {} + return E; +} +export { A }; +"; + let result = transform_angular_file(&allocator, "a.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + // The emitter wraps long lines. + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + for (name, line) in [("A", 4), ("B", 10), ("C", 12), ("D", 16), ("E", 22)] { + assert!( + code.contains(&format!( + "className:\"{name}\",filePath:\"a.component.ts\",lineNumber:{line}}}" + )), + "{name} is on line {line}. Got:\n{}", + result.code + ); + } + + let source = "import { Component } from '@angular/core'; +@Component({ selector: 'h', template: '' }) +export default class +// c +H {} +"; + let result = transform_angular_file(&allocator, "h.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + assert!(result.code.contains("lineNumber:3"), "H is on line 3. Got:\n{}", result.code); +} + #[test] fn test_jit_true_ng_module_skips_aot() { let allocator = Allocator::default(); diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 5d16b48e2..7bbd0b0b7 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -303,11 +303,11 @@ fn test_link_sanitizes_img_video_src_before_22_2_2() { for version in [AngularVersion::new(21, 2, 0), AngularVersion::new(22, 2, 1)] { let code = link_for_version(SRC_BINDINGS_COMPONENT, Some(version)); assert_eq!( - code.matches(r#"ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#).count(), + code.matches(r#"ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#).count(), 2, "{version:?}: img and video src must be sanitized:\n{code}" ); - assert!(code.contains(r#"ɵɵproperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); + assert!(code.contains(r#"ɵɵdomProperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); } } @@ -318,11 +318,11 @@ fn test_link_does_not_sanitize_img_video_src_from_22_2_2() { for version in [Some(AngularVersion::new(22, 2, 2)), None] { let code = link_for_version(SRC_BINDINGS_COMPONENT, version); assert_eq!( - code.matches(r#"ɵɵproperty("src",ctx.url)"#).count(), + code.matches(r#"ɵɵdomProperty("src",ctx.url)"#).count(), 2, "{version:?}: img and video src must not be sanitized:\n{code}" ); - assert!(code.contains(r#"ɵɵproperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); + assert!(code.contains(r#"ɵɵdomProperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); } let allocator = Allocator::default(); @@ -555,19 +555,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.0", ngImpor } /// When the linker wraps a define call in an IIFE to declare template or host -/// binding functions and pooled constants, the IIFE itself is annotated. Angular's -/// linker hoists those declarations and leaves `/* @__PURE__ */ i0.ɵɵdefine*(...)` -/// as the static field value, so an unannotated IIFE would keep unused classes alive. +/// binding functions and pooled constants (where `ngImport` is bound in an arrow +/// function, which cannot hold shared constants), the IIFE itself is annotated: +/// an unannotated IIFE would keep unused classes alive. #[test] fn test_link_declaration_iife_is_pure() { let allocator = Allocator::default(); - let code = r#"import * as i0 from "@angular/core"; + let code = r#"((i0) => { class C { static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.0", type: C, isStandalone: true, selector: "c", ngImport: i0, template: "{{x}}{{y}}" }); } class D { static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", type: D, isStandalone: true, selector: "[d]", ngImport: i0, host: { properties: { "style": "{ width: w }" } } }); } +})(core); "#; let result = link(&allocator, code, "test.mjs"); assert!(result.code.contains("static ɵcmp = /*@__PURE__*/ (() => {"), "{}", result.code); @@ -708,3 +709,479 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", n } assert!(!linked.contains("C_Defer_4_DepsFn"), "{}", result.code); } + +#[test] +fn test_link_query_predicate_splits_refs() { + // Angular's `getQueryPredicate` splits each string of the predicate on `,` + // and trims each part, keeping the empty ones, as ngtsc 22.2.3 partial + // output linked by Angular 22.2.3's linker shows. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", ngImport: i0, type: C, isStandalone: true, selector: "c", queries: [{ propertyName: "a", predicate: ["y, z"] }], viewQueries: [{ propertyName: "b", first: true, predicate: ["x, "], descendants: true }], template: "" }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(linked.contains(r#"const_c0=["y","z"];const_c1=["x",""];"#), "{}", result.code); + assert!(linked.contains("ɵɵcontentQuery(dirIndex,_c0,4)"), "{}", result.code); + assert!(linked.contains("ɵɵviewQuery(_c1,5)"), "{}", result.code); +} + +#[test] +fn test_link_component_pools_query_predicates() { + // Angular's linker pools string predicates like ngtsc, with the + // component's one pool: content queries, then view queries, then the host + // bindings' constants, then the template's, which reuses `["*"]`. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.2.2", type: C, isStandalone: true, selector: "c", host: { properties: { "attr.a": "[1, 2]" } }, queries: [{ propertyName: "c", first: true, predicate: ["y"], descendants: true }], viewQueries: [{ propertyName: "s", first: true, predicate: ["z"], descendants: true, isSignal: true }, { propertyName: "q", first: true, predicate: ["*"], descendants: true }, { propertyName: "t", first: true, predicate: TemplateRef, descendants: true }, { propertyName: "r", first: true, predicate: ["z"], descendants: true }], ngImport: i0, template: '', isInline: true }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + // Fragments of the TS linker's output. + for expected in [ + r#"const_c0=["y"];const_c1=["z"];const_c2=["*"];const_c3=()=>[1,2];"#, + "i0.ɵɵcontentQuery(dirIndex,_c0,5);", + "i0.ɵɵviewQuerySignal(ctx.s,_c1,5);", + "i0.ɵɵviewQuery(_c2,5)(TemplateRef,5)(_c1,5);", + "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c3));", + "ngContentSelectors:_c2,", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } +} + +#[test] +fn test_link_directive_pools_query_predicates() { + // As in a component, the predicates are pooled before the host bindings' + // constants. + let code = r#"import * as i0 from "@angular/core"; +export class D {} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.2", type: D, isStandalone: true, selector: "[d]", host: { properties: { "attr.a": "[1, 2]" } }, queries: [{ propertyName: "c", first: true, predicate: ["y"], descendants: true }, { propertyName: "cs", predicate: ["y, z"] }], viewQueries: [{ propertyName: "q", first: true, predicate: ["y"], descendants: true }], ngImport: i0 }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + // Fragments of the TS linker's output. + for expected in [ + r#"const_c0=["y"];const_c1=["y","z"];const_c2=()=>[1,2];"#, + "i0.ɵɵcontentQuery(dirIndex,_c0,5)(dirIndex,_c1,4);", + "i0.ɵɵviewQuery(_c0,5);", + "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c2));", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } +} + +#[test] +fn test_link_query_predicate_pools_lone_surrogates() { + // A predicate with a lone surrogate is split and pooled like any other, as + // Angular 22.2.3's linker shows, so the later constants keep their names. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: C, isStandalone: true, selector: "c", host: { properties: { "attr.a": "[1, 2]" } }, viewQueries: [{ propertyName: "q", first: true, predicate: ["\uD800"], descendants: true }, { propertyName: "r", first: true, predicate: ["y"], descendants: true }, { propertyName: "s", first: true, predicate: ["\uD800, z"], descendants: true }], ngImport: i0, template: "", isInline: true }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + for expected in [ + r#"const_c0=["\uD800"];const_c1=["y"];const_c2=["\uD800","z"];const_c3=()=>[1,2];"#, + "i0.ɵɵviewQuery(_c0,5)(_c1,5)(_c2,5);", + "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c3));", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } +} + +#[test] +fn test_link_query_predicate_trims_like_js() { + // The parts are trimmed with `String.prototype.trim`, which strips U+FEFF + // but not U+0085, as Angular 22.2.3's linker shows. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", ngImport: i0, type: C, isStandalone: true, selector: "c", viewQueries: [{ propertyName: "q", first: true, predicate: ["\uFEFFx"], descendants: true }, { propertyName: "r", first: true, predicate: ["y\u0085"], descendants: true }], template: "" }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains(r#"["x"]"#), "{}", result.code); + assert!(result.code.contains("[\"y\u{85}\"]"), "{}", result.code); +} + +/// A component (standalone by default at version 22) with the given extra metadata. +fn dom_only_component(extra: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class A {{}} +A.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "22.2.2", type: A, selector: "a", ngImport: i0, template: '
@defer {{}}', isInline: true{extra} }}); +"# + ) +} + +/// Angular's linker compiles a standalone template in DomOnly mode unless it has a +/// directive, component or NgModule dependency, or a `@defer` block with dependencies, +/// as Angular 22.2.3's linker shows. DomOnly needs Angular 20.1. +#[test] +fn test_link_dom_only_template() { + let dom_only = [ + r#"ɵɵdomElementStart(0,"div",0);"#, + r#"ɵɵdomListener("click",functionA_Template_div_click_0_listener()"#, + r#"ɵɵdomProperty("title",ctx.t);"#, + r#"ɵɵdomElement(0,"i");"#, + ]; + let full = [ + r#"ɵɵelementStart(0,"div",0);"#, + r#"ɵɵlistener("click",functionA_Template_div_click_0_listener()"#, + r#"ɵɵproperty("title",ctx.t);"#, + r#"ɵɵelement(0,"i");"#, + ]; + let cases = [ + ("", None, &dom_only), + (r#", dependencies: [{ kind: "pipe", type: P, name: "p" }]"#, None, &dom_only), + (", deferBlockDependencies: [null]", None, &dom_only), + ("", Some(AngularVersion::new(20, 1, 0)), &dom_only), + (r#", dependencies: [{ kind: "directive", type: D, selector: "d" }]"#, None, &full), + (r#", dependencies: [{ kind: "component", type: D, selector: "d" }]"#, None, &full), + (r#", dependencies: [{ kind: "ngmodule", type: M }]"#, None, &full), + (r#", deferBlockDependencies: [() => [import("./x").then(m => m.X)]]"#, None, &full), + (", isStandalone: false", None, &full), + ("", Some(AngularVersion::new(20, 0, 0)), &full), + ]; + for (extra, version, expected) in cases { + let code = link_for_version(&dom_only_component(extra), version); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + for expected in expected { + assert!( + linked.contains(expected), + "{extra} {version:?}: missing `{expected}` in\n{code}" + ); + } + } +} + +/// Partial declarations of three classes with equal constants, as ngtsc 22.2.3 emits them. +const SHARED_CONSTANTS_FILE: &str = r#"import * as i0 from "@angular/core"; +export class A {} +A.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: A, isStandalone: true, selector: "a", viewQueries: [{ propertyName: "f", first: true, predicate: ["foo"], descendants: true }, { propertyName: "b", first: true, predicate: ["bar"], descendants: true }], ngImport: i0, template: '

Hi {{n}}

@for (x of xs; track x.id) {{{x}}}', isInline: true }); +export class B {} +B.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: B, isStandalone: true, selector: "b", viewQueries: [{ propertyName: "f", first: true, predicate: ["foo"], descendants: true }, { propertyName: "q", first: true, predicate: ["qux"], descendants: true }], ngImport: i0, template: '

Bye {{n}}

@for (x of xs; track x.id) {{{x}}}', isInline: true }); +export class D {} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.2", type: D, isStandalone: true, selector: "[d]", host: { properties: { "attr.y": "[1,2]" } }, ngImport: i0 }); +"#; + +/// Like Angular's linker, the declarations of a module share one constant pool: the names +/// go on across the file, equal constants are declared once, and the constants and child +/// template functions go after the imports, in the order Angular 22.2.3's linker emits them, +/// with the main template function inline. +#[test] +fn test_link_shares_constants_across_a_module() { + let allocator = Allocator::default(); + let result = link(&allocator, SHARED_CONSTANTS_FILE, "/x/cases/main.js"); + assert!(result.linked); + let code = &result.code; + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let mut last = 0; + for expected in [ + r#"import*asi0from"@angular/core";const_c0=["foo"];const_c1=["bar"];const_c2=()=>[1,2];const_forTrack0=($index,$item)=>$item.id;functionA_For_4_Template(rf,ctx){"#, + r#"const_c3=["qux"];functionB_For_4_Template(rf,ctx){"#, + "exportclassA{}A.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent({type:A,", + "i0.ɵɵviewQuery(_c0,5)(_c1,5);", + "i18n_0=MSG__X_CASES_MAIN_JS_0", + "template:functionA_Template(rf,ctx){", + r#"i0.ɵɵrepeaterCreate(3,A_For_4_Template,2,1,"b",null,_forTrack0);"#, + r#"i0.ɵɵattribute("x",i0.ɵɵpureFunction0(2,_c2));"#, + "exportclassB{}B.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent({type:B,", + "i0.ɵɵviewQuery(_c0,5)(_c3,5);", + "i18n_1=MSG__X_CASES_MAIN_JS_1", + r#"i0.ɵɵrepeaterCreate(3,B_For_4_Template,2,1,"u",null,_forTrack0);"#, + r#"i0.ɵɵattribute("x",i0.ɵɵpureFunction0(2,_c2));"#, + "exportclassD{}D.ɵdir=/*@__PURE__*/i0.ɵɵdefineDirective({type:D,", + r#"i0.ɵɵattribute("y",i0.ɵɵpureFunction0(1,_c2));"#, + ] { + let Some(at) = linked[last..].find(expected) else { + panic!("missing `{expected}` after byte {last} in\n{code}"); + }; + last += at + expected.len(); + } + assert!(!code.contains("(() =>"), "no IIFE:\n{code}"); +} + +/// A long style is inline on its first use and shared from its second, across the +/// declarations of a module: as in Angular 22.2.3's linker output, a first use in an +/// earlier declaration stays inline, and the constants go after the imports. +#[test] +fn test_link_shares_long_styles_across_a_module() { + let a = ".a { color: red; background: blue; border: 1px solid green; padding: 4px; }"; + let b = ".b { color: red; background: blue; border: 1px solid green; padding: 4px; }"; + let component = |name: &str, styles: &[&str]| { + let styles: Vec = styles.iter().map(|style| format!("{style:?}")).collect(); + format!( + r#"export class {name} {{}} +{name}.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "22.2.2", type: {name}, isStandalone: true, selector: "{name}", ngImport: i0, template: "

", isInline: true, styles: [{}] }}); +"#, + styles.join(", ") + ) + }; + let code = format!( + "import * as i0 from \"@angular/core\";\n// comment\n{}{}{}", + component("A", &[a, b]), + component("B", &[b, b, a]), + component("C", &[a]) + ); + let allocator = Allocator::default(); + let result = link(&allocator, &code, "test.mjs"); + assert!(result.linked); + let scoped_a = r#"".a[_ngcontent-%COMP%] { color: red; background: blue; border: 1px solid green; padding: 4px; }""#; + let scoped_b = r#"".b[_ngcontent-%COMP%] { color: red; background: blue; border: 1px solid green; padding: 4px; }""#; + let expected = format!( + "import * as i0 from \"@angular/core\";\n// comment\nconst _c0 = {scoped_b};\nconst _c1 = {scoped_a};\nexport class A {{}}" + ); + assert!(result.code.starts_with(&expected), "{}", result.code); + for expected in [ + format!("styles: [{scoped_a}, {scoped_b}]"), + "styles: [_c0, _c0, _c1]".to_string(), + "styles: [_c1]".to_string(), + ] { + assert!(result.code.contains(&expected), "missing `{expected}` in\n{}", result.code); + } +} + +/// Angular's linker shares the constants of the declarations whose `ngImport` is bound in +/// a function declaration or expression, at the start of its body (after the directives +/// and the comments after them), and keeps the others' local in an IIFE: an arrow +/// function or a method cannot hold them. As Angular 22.2.3's linker shows for a UMD bundle. +#[test] +fn test_link_constant_scopes() { + let component = |name: &str| { + format!( + r#"class {name} {{}} {name}.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "22.2.2", type: {name}, isStandalone: true, selector: "{name}", ngImport: i0, template: "", isInline: true }});"# + ) + }; + let code = format!( + r#"(function (global, factory) {{ factory(exports, require("@angular/core")); }})(this, (function (exports, i0) {{ 'use strict'; + /** c */ + {} + {} + const f = function (i0) {{ {} }}; + const g = (i0) => {{ {} }}; + class E {{ m(i0) {{ {} }} }} +}})); +"#, + component("A"), + component("B"), + component("F"), + component("G"), + component("M") + ); + let allocator = Allocator::default(); + let result = link(&allocator, &code, "test.js"); + assert!(result.linked); + let code = &result.code; + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let mut last = 0; + for expected in [ + "(function(exports,i0){'usestrict';/**c*/const_c0=()=>[1,2];classA{}A.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent(", + "i0.ɵɵpureFunction0(1,_c0)", + "classB{}B.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent(", + "i0.ɵɵpureFunction0(1,_c0)", + "constf=function(i0){const_c0=()=>[1,2];classF{}F.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent(", + "constg=(i0)=>{classG{}G.ɵcmp=/*@__PURE__*/(()=>{const_c0=()=>[1,2];", + "classE{m(i0){classM{}M.ɵcmp=/*@__PURE__*/(()=>{const_c0=()=>[1,2];", + ] { + let Some(at) = linked[last..].find(expected) else { + panic!("missing `{expected}` after byte {last} in\n{code}"); + }; + last += at + expected.len(); + } +} + +/// The host binding functions are named with the scope's pool, like the template +/// functions: as Angular 22.2.3's linker shows, a second class `A` of the module gets +/// `A_HostBindings1`. +#[test] +fn test_link_names_host_bindings_across_a_module() { + let code = r#"import * as i0 from "@angular/core"; +export function f1() { class A {} A.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: A, isStandalone: true, selector: "cmp-a", ngImport: i0, template: '@if (x) {

one

}', isInline: true, host: { listeners: { click: "go()" } } }); return A; } +export function f2() { class A {} A.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: A, isStandalone: true, selector: "cmp-a2", ngImport: i0, template: '@if (x) {

two

}', isInline: true, host: { listeners: { click: "go()" } } }); return A; } +export function f3() { class A {} A.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.0.0", version: "22.2.2", type: A, isStandalone: true, selector: "[d]", ngImport: i0, host: { properties: { title: "t" } } }); return A; } +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "/x/main.js"); + assert!(result.linked); + let code = &result.code; + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let mut last = 0; + for expected in [ + "hostBindings:functionA_HostBindings(rf,ctx){", + "template:functionA_Template(rf,ctx){", + "hostBindings:functionA_HostBindings1(rf,ctx){", + "template:functionA_Template1(rf,ctx){", + "hostBindings:functionA_HostBindings2(rf,ctx){", + ] { + let Some(at) = linked[last..].find(expected) else { + panic!("missing `{expected}` after byte {last} in\n{code}"); + }; + last += at + expected.len(); + } +} + +/// A `@defer` dependency function that uses a class of the function declaring the +/// component stays local with the declaration's other constants, as the base linker kept +/// it: Angular 22.2.3's linker moves it after the imports, where the class is not defined. +#[test] +fn test_link_keeps_defer_deps_with_inner_bindings_local() { + let code = r#"import * as i0 from "@angular/core"; +export function make() { + class Child {} + class A {} + A.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: A, isStandalone: true, selector: "cmp-a", ngImport: i0, template: '@defer { } @placeholder {

x

}', isInline: true, deferBlockDependencies: [() => [Child]] }); + return A; +} +export class B {} +B.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", type: B, isStandalone: true, selector: "cmp-b", ngImport: i0, template: '@defer { }', isInline: true, deferBlockDependencies: [() => [import("./c").then(m => m.C)]] }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "/x/main.js"); + assert!(result.linked); + let code = &result.code; + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let mut last = 0; + for expected in [ + r#"import*asi0from"@angular/core";constB_Defer_1_DepsFn=()=>[import("./c").then(m=>m.C)];"#, + "exportfunctionmake(){classChild{}classA{}A.ɵcmp=/*@__PURE__*/(()=>{constA_Defer_2_DepsFn=()=>[Child];", + "i0.ɵɵdefer(2,0,A_Defer_2_DepsFn,null,1);", + "exportclassB{}B.ɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent(", + "i0.ɵɵdefer(1,0,B_Defer_1_DepsFn);", + ] { + let Some(at) = linked[last..].find(expected) else { + panic!("missing `{expected}` after byte {last} in\n{code}"); + }; + last += at + expected.len(); + } +} + +/// Angular's linker names Closure messages after the linked file's path, without external +/// ids even for a custom id, as Angular 22.2.3's linker shows for `/x/lib/a.component.mjs`. +#[test] +fn test_link_i18n_closure_names_use_file_path() { + let code = r#"import * as i0 from "@angular/core"; +export class A {} +A.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: A, selector: "a", ngImport: i0, template: '

Hello

Bye

', isInline: true }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "/x/lib/a.component.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + for expected in [ + r#"constMSG__X_LIB_A_COMPONENT_MJS_0=goog.getMsg("Hello");"#, + "i18n_0=MSG__X_LIB_A_COMPONENT_MJS_0", + r#"constMSG__X_LIB_A_COMPONENT_MJS_1=goog.getMsg("Bye");"#, + "i18n_1=MSG__X_LIB_A_COMPONENT_MJS_1", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } +} + +/// `ɵɵngDeclareDirective` of ngtsc 22.2.3 for two signal and two decorator +/// view queries, and one signal and two decorator content queries. +const CHAINED_QUERIES_DIRECTIVE: &str = r#"import * as i0 from "@angular/core"; +export class D {} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.2.2", type: D, isStandalone: true, selector: "[d]", queries: [{ propertyName: "cs", predicate: ["a"], isSignal: true }, { propertyName: "b", predicate: ["b"] }, { propertyName: "c", predicate: ["c"] }], viewQueries: [{ propertyName: "s", first: true, predicate: ["s"], descendants: true, isSignal: true }, { propertyName: "r", first: true, predicate: ["r"], descendants: true, isSignal: true }, { propertyName: "q", first: true, predicate: ["q"], descendants: true }, { propertyName: "p", first: true, predicate: ["p"], descendants: true }], ngImport: i0 }); +"#; + +/// Consecutive query creation calls of one instruction are chained, as Angular +/// 22.2.3's linker does, except for runtimes before v21.0.4, whose query +/// instructions return nothing. Consecutive signal queries advance once. +#[test] +fn test_link_chains_query_creation() { + for version in [None, Some(AngularVersion::new(21, 0, 4))] { + let code = link_for_version(CHAINED_QUERIES_DIRECTIVE, version); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + for expected in [ + "{i0.ɵɵcontentQuerySignal(dirIndex,ctx.cs,_c0,4);i0.ɵɵcontentQuery(dirIndex,_c1,4)(dirIndex,_c2,4);}", + "{i0.ɵɵviewQuerySignal(ctx.s,_c3,5)(ctx.r,_c4,5);i0.ɵɵviewQuery(_c5,5)(_c6,5);}", + "{i0.ɵɵqueryAdvance();let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.b=_t);", + "{i0.ɵɵqueryAdvance(2);let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.q=_t.first);", + ] { + assert!(linked.contains(expected), "{version:?}: missing `{expected}` in\n{code}"); + } + } + + let code = link_for_version(CHAINED_QUERIES_DIRECTIVE, Some(AngularVersion::new(21, 0, 3))); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(linked.contains("i0.ɵɵviewQuery(_c5,5);i0.ɵɵviewQuery(_c6,5);"), "{code}"); + assert!(!linked.contains(",4)(") && !linked.contains(",5)("), "{code}"); +} + +/// `ɵɵngDeclareComponent` of ngtsc 21.2.25 for +/// `@Component({ selector: 'span[bitBadge]', template: '' })`. +const BADGE_COMPONENT: &str = r#"import * as i0 from "@angular/core"; +export class BadgeComponent {} +BadgeComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.25", type: BadgeComponent, isStandalone: true, selector: "span[bitBadge]", ngImport: i0, template: '', isInline: true }); +"#; + +/// Angular before v22.0.0-next.4 sets `attrs` to the first selector's +/// attributes, pooled after the host bindings' constants and before the +/// template's, as Angular 21.2.25's linker does. +#[test] +fn test_link_selector_attrs_before_v22() { + let code = link_for_version(BADGE_COMPONENT, Some(AngularVersion::new(21, 2, 25))); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(linked.contains(r#"const_c0=["bitBadge",""];const_c1=["*"];"#), "{code}"); + assert!(linked.contains("attrs:_c0,ngContentSelectors:_c1,"), "{code}"); + + for version in [Some(AngularVersion::new(22, 0, 0)), None] { + let code = link_for_version(BADGE_COMPONENT, version); + assert!(!code.contains("attrs:"), "{version:?}:\n{code}"); + assert!(code.contains(r#"const _c0 = ["*"];"#), "{version:?}:\n{code}"); + } + + // After the query predicates and the host bindings' constants. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.25", type: C, isStandalone: true, selector: "span[bitBadge]", host: { properties: { "attr.a": "[1, 2]" } }, viewQueries: [{ propertyName: "q", first: true, predicate: ["x"], descendants: true }], ngImport: i0, template: '', isInline: true }); +"#; + let code = link_for_version(code, Some(AngularVersion::new(21, 2, 25))); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + linked.contains( + r#"const_c0=["x"];const_c1=()=>[1,2];const_c2=["bitBadge",""];const_c3=["*"];"# + ), + "{code}" + ); + assert!(linked.contains("attrs:_c2,ngContentSelectors:_c3,"), "{code}"); + + // Shared with an equal predicate. + let code = r#"import * as i0 from "@angular/core"; +export class D {} +D.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.25", type: D, isStandalone: true, selector: "[x]", viewQueries: [{ propertyName: "q", first: true, predicate: ["x, "], descendants: true }], ngImport: i0, template: '', isInline: true }); +"#; + let code = link_for_version(code, Some(AngularVersion::new(21, 2, 25))); + let linked: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert_eq!(linked.matches("const_c").count(), 1, "{code}"); + assert!(linked.contains(r#"const_c0=["x",""];"#), "{code}"); + assert!(linked.contains("ɵɵviewQuery(_c0,5)"), "{code}"); + assert!(linked.contains("},attrs:_c0,decls:0,"), "{code}"); +} + +/// Angular's linker compiles with `i18nUseExternalIds: false`, so a Closure message is named +/// `MSG_`, never after its message id (`MSG_EXTERNAL_$$...`). +#[test] +fn test_link_i18n_closure_names_do_not_use_message_ids() { + let code = link_for_version( + r#" +import * as i0 from "@angular/core"; +class Greet { +} +Greet.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.0", type: Greet, isStandalone: true, selector: "x-greet", ngImport: i0, template: '

Hello

' }); +"#, + None, + ); + assert!(!code.contains("MSG_EXTERNAL_"), "{code}"); + assert!(code.contains("const MSG_"), "{code}"); +} diff --git a/crates/oxc_angular_compiler/tests/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs new file mode 100644 index 000000000..21bf43ffd --- /dev/null +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -0,0 +1,615 @@ +//! ngtsc compiles a decorated class declared in a function body, block or +//! callback like a top-level one: its definitions go on the class and its +//! metadata calls right after it, in the same scope, while the constants and +//! child template functions its compile declares go to the file top level, +//! in the file's one constant pool. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + CompilationMode, ComponentHmrUpdateInput, DtsInjectDeclaration, ResolvedResources, + TransformOptions, TransformResult, compile_component_hmr_update, + extract_component_resource_urls, inject_dts_declarations, transform_angular_file, +}; + +fn transform_with(source: &str, options: &TransformOptions) -> TransformResult { + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "test.ts", source, Some(options), None); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + result +} + +fn transform(source: &str) -> String { + transform_with(source, &TransformOptions::default()).code +} + +/// `code` without whitespace, so the emitter's line wrapping doesn't matter. +fn squash(code: &str) -> String { + code.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// The names of the top-level `const`s of `code`, in order. +fn constants(code: &str) -> Vec<&str> { + code.lines() + .filter_map(|line| line.strip_prefix("const ")) + .filter_map(|rest| rest.split([' ', '=']).next()) + .collect() +} + +/// The byte offset of `needle` in `code`. +fn offset(code: &str, needle: &str) -> usize { + code.find(needle).unwrap_or_else(|| panic!("no {needle:?} in\n{code}")) +} + +const IIFE: &str = r" +import { Component } from '@angular/core'; +export const X = (() => { + @Component({ selector: '[a]', template: '', standalone: true }) + class Inner {} + return Inner; +})(); +@Component({ selector: '[a]', template: '', standalone: true }) +export class Outer {} +"; + +#[test] +fn compiles_a_component_declared_in_an_iife() { + // ngtsc: `_c0` is declared once, after the imports, and both classes use + // it; Inner's metadata calls stay in the arrow, before `return Inner`. + let code = transform(IIFE); + let squashed = squash(&code); + assert!(!code.contains("@Component("), "{code}"); + assert_eq!(constants(&code), ["_c0"], "{code}"); + assert!(offset(&code, "const _c0") < offset(&code, "export const X"), "{code}"); + assert!( + squashed.contains("classInner{staticɵfac=functionInner_Factory("), + "Inner gets its definitions:\n{code}" + ); + assert!(squashed.contains("type:Inner,selectors:[[\"\",\"a\",\"\"]],ngContentSelectors:_c0")); + assert!(squashed.contains("type:Outer,selectors:[[\"\",\"a\",\"\"]],ngContentSelectors:_c0")); + let metadata = offset(&code, "ɵsetClassMetadata(Inner"); + assert!(metadata < offset(&code, "return Inner;"), "{code}"); + assert!(offset(&code, "ɵsetClassDebugInfo(Inner") < offset(&code, "return Inner;"), "{code}"); +} + +#[test] +fn compiles_each_kind_of_decorated_class_in_a_function_block_or_callback() { + let code = transform( + r" +import { Component, Directive, Pipe, Injectable, NgModule } from '@angular/core'; +declare const describe: any; +export function make() { + @Directive({ selector: '[fd]', standalone: true }) + class FnDir {} + @Pipe({ name: 'fp', standalone: true }) + class FnPipe { transform(v: any) { return v; } } + @Injectable() + class FnSvc {} + @NgModule({}) + class FnMod {} + return [FnDir, FnPipe, FnSvc, FnMod]; +} +{ + @Component({ selector: 'blk', template: '

hi

', standalone: true }) + class BlockCmp {} +} +describe('x', () => { + @Component({ selector: 'cb', template: '

cb

', standalone: true }) + class CallbackCmp {} +}); +", + ); + for decorator in ["@Component(", "@Directive(", "@Pipe(", "@Injectable(", "@NgModule("] { + assert!(!code.contains(decorator), "{code}"); + } + let squashed = squash(&code); + for definition in [ + "staticɵdir=/*@__PURE__*/i0.ɵɵdefineDirective({type:FnDir", + "staticɵpipe=/*@__PURE__*/i0.ɵɵdefinePipe({name:\"fp\",type:FnPipe", + "staticɵprov=/*@__PURE__*/i0.ɵɵdefineInjectable({token:FnSvc", + "staticɵmod=/*@__PURE__*/i0.ɵɵdefineNgModule({type:FnMod", + "staticɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent({type:BlockCmp", + "staticɵcmp=/*@__PURE__*/i0.ɵɵdefineComponent({type:CallbackCmp", + ] { + assert!(squashed.contains(definition), "no {definition} in\n{code}"); + } +} + +#[test] +fn classes_of_one_name_in_two_functions_keep_their_own_definitions() { + // ngtsc names the second class's template functions `Tp_Template1` and + // `Tp_Conditional_0_Template1`, all declared at the file top level. + let code = transform( + r" +import { Component } from '@angular/core'; +export function mk() { + @Component({ selector: 'tp', template: '@if (a) {{{a}}}', standalone: true }) + class Tp { a = 1; } + return Tp; +} +export function mk2() { + @Component({ selector: 'tp2', template: '@if (a) {{{a}}}', standalone: true }) + class Tp { a = 1; } + return Tp; +} +", + ); + let squashed = squash(&code); + assert_eq!(squashed.matches("selectors:[[\"tp\"]]").count(), 1, "{code}"); + assert_eq!(squashed.matches("selectors:[[\"tp2\"]]").count(), 1, "{code}"); + assert!(code.contains("\nfunction Tp_Conditional_0_Template(rf,ctx)"), "{code}"); + assert!(code.contains("\nfunction Tp_Conditional_0_Template1(rf,ctx)"), "{code}"); + assert!( + squashed.contains("selectors:[[\"tp2\"]],decls:1,vars:1,template:functionTp_Template1("), + "{code}" + ); + assert!(offset(&code, "Tp_Conditional_0_Template1(rf") < offset(&code, "function mk2")); +} + +#[test] +fn compiles_a_class_in_a_member_before_the_class_and_declares_its_constants_first() { + // ngtsc compiles M before O: M pools `_c0` and `_c1`, O `_c2`. All go + // before `export class O`, past the decorator compilation removes. + let code = transform( + r" +import { Component } from '@angular/core'; +@Component({ selector: 'o', template: '', standalone: true }) +export class O { + make() { + @Component({ selector: 'm', template: '', standalone: true }) + class M {} + return M; + } +} +", + ); + assert_eq!(constants(&code), ["_c0", "_c1", "_c2"], "{code}"); + assert!(offset(&code, "const _c2") < offset(&code, "export class O"), "{code}"); + assert!(!code.contains("@Component("), "{code}"); + let squashed = squash(&code); + assert!(squashed.contains("type:M,selectors:[[\"m\"]],ngContentSelectors:_c1"), "{code}"); + assert!(squashed.contains("i0.ɵɵprojectionDef(_c0)"), "{code}"); + assert!(squashed.contains("type:O,selectors:[[\"o\"]],ngContentSelectors:_c2"), "{code}"); +} + +#[test] +fn keeps_the_decorators_of_a_nested_class_reading_an_enclosing_binding() { + // The decorator evaluator only sees top-level bindings: ngtsc folds the + // local `sel` ("local") and `tpl`; this class stays for the JIT compiler + // rather than be compiled with the top-level `sel`. + let source = r" +import { Component } from '@angular/core'; +const sel = 'top'; +export function f() { + const sel = 'local'; + @Component({ selector: sel, template: '', standalone: true }) + class Shadowed {} + const tpl = 'x'; + @Component({ selector: 'lr', template: tpl, standalone: true }) + class LocalTemplate {} + return [Shadowed, LocalTemplate]; +} +"; + let code = transform(source); + assert_eq!(code, source); +} + +#[test] +fn keeps_the_decorators_of_a_nested_class_injecting_a_shadowing_local_class() { + // ngtsc injects the local LSvc; the import map only knows the imported + // one, so A stays for the JIT compiler. The other classes compile. + let source = r" +import { Component, Injectable } from '@angular/core'; +import { LSvc } from './svc'; +export const G = LSvc; +export function f() { + @Injectable() class LSvc {} + @Injectable() class Own {} + @Component({ selector: 'x', template: '', standalone: true, providers: [Own] }) + class A { constructor(s: LSvc, o: Own) {} } + return A; +} +"; + for compilation_mode in [CompilationMode::Full, CompilationMode::Partial] { + let code = + transform_with(source, &TransformOptions { compilation_mode, ..Default::default() }) + .code; + assert!(!code.contains("i1.LSvc"), "{code}"); + assert!(code.contains("@Component({ selector: 'x'"), "{code}"); + assert!(!code.contains("@Injectable()"), "{code}"); + } +} + +#[test] +fn declares_the_constants_of_a_nested_class_after_the_imports() { + // `make` runs before its statement: ngtsc declares `_c0` after the + // imports, so `M`'s `ngContentSelectors` doesn't read it in its TDZ. + let code = transform( + r" +import { Component } from '@angular/core'; +export const Made = make(); +function make() { + @Component({ selector: 'm', template: '', standalone: true }) + class M {} + return M; +} +", + ); + assert_eq!(constants(&code), ["_c0"], "{code}"); + assert!(offset(&code, "import * as i0") < offset(&code, "const _c0"), "{code}"); + assert!(offset(&code, "const _c0") < offset(&code, "export const Made"), "{code}"); +} + +#[test] +fn declares_a_constant_a_nested_class_shares_with_an_earlier_class_after_the_imports() { + // `B` reuses the `_c0` that `A` pooled first; `make` runs before `A`'s + // statement, so `_c0` goes after the imports, as in ngtsc. + let code = transform( + r" +import { Component } from '@angular/core'; +export const Made = make(); +@Component({ selector: 'a-a', template: '' }) +export class A {} +function make() { + @Component({ selector: 'b-b', template: '' }) + class B {} + return B; +} +", + ); + assert_eq!(constants(&code), ["_c0"], "{code}"); + assert!(offset(&code, "import * as i0") < offset(&code, "const _c0"), "{code}"); + assert!(offset(&code, "const _c0") < offset(&code, "export const Made"), "{code}"); + assert_eq!(squash(&code).matches("ngContentSelectors:_c0").count(), 2, "{code}"); +} + +#[test] +fn compiles_a_nested_class_referencing_its_own_or_local_classes() { + let code = transform( + r" +import { Component, forwardRef, InjectionToken } from '@angular/core'; +const T = new InjectionToken('t'); +export function f() { + @Component({ selector: 'h', template: '', standalone: true }) + class Helper {} + @Component({ + selector: 'sf', + template: '', + standalone: true, + imports: [Helper], + providers: [{ provide: T, useExisting: forwardRef(() => Sf) }, { provide: 'x', useFactory: (a: any) => a, deps: [T] }], + }) + class Sf {} + return Sf; +} +", + ); + assert!(!code.contains("@Component("), "{code}"); + let squashed = squash(&code); + assert!(squashed.contains("useExisting:forwardRef(()=>Sf)"), "{code}"); + assert!(squashed.contains("i0.ɵɵgetComponentDepsFactory(Sf,[Helper])"), "{code}"); +} + +#[test] +fn compiles_a_nested_class_in_partial_mode() { + let options = + TransformOptions { compilation_mode: CompilationMode::Partial, ..Default::default() }; + let code = transform_with(IIFE, &options).code; + let squashed = squash(&code); + assert!(!code.contains("@Component("), "{code}"); + assert!(squashed.contains("classInner{staticɵfac=i0.ɵɵngDeclareFactory("), "{code}"); + assert!(squashed.contains("staticɵcmp=i0.ɵɵngDeclareComponent({"), "{code}"); + assert!(offset(&code, "ɵɵngDeclareClassMetadata(") < offset(&code, "return Inner;"), "{code}"); +} + +#[test] +fn keeps_the_default_import_a_nested_factory_injects() { + // ngtsc: `i0.ɵɵdirectiveInject(Svc)` names the default import as written. + let code = transform( + r" +import { Component } from '@angular/core'; +import Svc from './svc'; +export function mk() { + @Component({ selector: 'ct', template: '', standalone: true }) + class Ct { constructor(s: Svc) {} } + return Ct; +} +", + ); + assert!(code.contains("import Svc from './svc';"), "{code}"); + assert!(code.contains("i0.ɵɵdirectiveInject(Svc)"), "{code}"); +} + +#[test] +fn leaves_a_nested_class_out_of_the_dts() { + let result = transform_with(IIFE, &TransformOptions::default()); + let classes: Vec<_> = result.dts_declarations.iter().map(|d| d.class_name.as_str()).collect(); + assert_eq!(classes, ["Outer"]); +} + +#[test] +fn reads_and_updates_the_template_of_a_nested_class() { + let source = r" +import { Component } from '@angular/core'; +export function f() { + @Component({ selector: 'n', templateUrl: './n.html', standalone: true }) + class N {} + return N; +} +"; + let allocator = Allocator::default(); + let urls = extract_component_resource_urls(&allocator, "/app/n.ts", source, None); + assert_eq!(urls.template_urls, ["./n.html"]); + + let resources = ResolvedResources { + templates: std::iter::once(("./n.html".to_owned(), "new".to_owned())).collect(), + styles: std::collections::HashMap::default(), + }; + let input = ComponentHmrUpdateInput { class_name: "N", ..Default::default() }; + let update = compile_component_hmr_update( + &allocator, + "/app/n.ts", + source, + &input, + None, + Some(&resources), + ); + assert!(update.diagnostics.is_empty(), "{:?}", update.diagnostics); + assert!(update.module.is_some()); + assert!(update.template_js.contains("\"i\""), "{}", update.template_js); +} + +#[test] +fn compiles_two_nested_classes_of_one_name_without_hmr() { + // HMR finds a component by `{path}@{name}`, which names neither `Tp`: an + // update for one would replace the other's template too. + let source = r" +import { Component } from '@angular/core'; +export function a() { + @Component({ selector: 'a', template: 'a', standalone: true }) + class Tp {} + return Tp; +} +export function b() { + @Component({ selector: 'b', template: 'b', standalone: true }) + class Tp {} + return Tp; +} +@Component({ selector: 'c', template: 'c', standalone: true }) +export class Other {} +"; + let result = transform_with(source, &TransformOptions { hmr: true, ..Default::default() }); + let code = &result.code; + assert_eq!(code.matches("ɵɵdefineComponent({type:Tp,").count(), 2, "{code}"); + assert!(!code.contains("Tp_HmrLoad"), "{code}"); + assert!(code.contains("Other_HmrLoad"), "{code}"); + assert!(!result.template_updates.keys().any(|id| id.ends_with("@Tp")), "{code}"); + + let allocator = Allocator::default(); + let input = ComponentHmrUpdateInput { class_name: "Tp", ..Default::default() }; + let update = compile_component_hmr_update(&allocator, "test.ts", source, &input, None, None); + assert!(update.module.is_none()); +} + +#[test] +fn keeps_the_decorators_of_a_nested_class_calling_a_shadowing_initializer() { + // `make`'s `input` shadows the imported one: ngtsc sees no signal input on + // `C` (only on `D`). The evaluator only sees top-level bindings, so `C` + // keeps its decorators, like a class reading any enclosing binding. + let code = transform( + r" +import { Component, input } from '@angular/core'; +export function make(input: (n: number) => number) { + @Component({ selector: 'c-c', template: '' }) + class C { value = input(0); other = input.required(); } + return C; +} +@Component({ selector: 'd-d', template: '' }) +export class D { v = input(1); } +", + ); + let squashed = squash(&code); + assert!(code.contains("@Component({ selector: 'c-c'"), "{code}"); + assert!(!squashed.contains("inputs:{value:"), "{code}"); + assert!(squashed.contains("type:D,selectors:[[\"d-d\"]],inputs:{v:[1,\"v\"]}"), "{code}"); +} + +const NAMESPACES: &str = r" +import { Component, Injectable, NgModule } from '@angular/core'; +export namespace NS { + @Component({ selector: 'n-n', template: '' }) + export class N {} + @Component({ selector: 'm-m', template: '' }) + class M {} + export const used = M; +} +namespace Inner.Deep { + @Injectable() + export class S {} + @NgModule({ imports: [] }) + export class Mod {} +} +namespace WithFn { + export function make() { + @Component({ selector: 'q-q', template: '' }) + class Q {} + return Q; + } +} +declare namespace Ambient { + @Component({ selector: 'z-z', template: '' }) + class Z {} +} +"; + +#[test] +fn compiles_a_namespace_member_without_statements_after_it() { + // ngtsc compiles a `namespace` member and hoists its constants, but only + // adds statements after a class in a block or the file: no metadata or + // debug info for `N`, `M`, `S` and `Mod`, while `Q`, in a function, gets + // them. An ambient namespace is left alone. + let result = transform_with(NAMESPACES, &TransformOptions::default()); + let code = &result.code; + let squashed = squash(code); + assert_eq!(constants(code), ["_c0", "_c1"], "{code}"); + assert!(offset(code, "const _c0") < offset(code, "export namespace NS"), "{code}"); + for name in ["N", "M", "S", "Mod", "Q"] { + assert!(squashed.contains(&format!("{name}_Factory")), "{name}: {code}"); + } + assert!(squashed.contains("ɵɵdefineNgModule({type:Mod"), "{code}"); + assert!(code.contains("@Component({ selector: 'z-z'"), "{code}"); + assert_eq!(code.matches("ɵsetClassMetadata(").count(), 1, "{code}"); + assert!(code.contains("ɵsetClassMetadata(Q,"), "{code}"); + assert_eq!(code.matches("ɵsetClassDebugInfo(").count(), 1, "{code}"); + assert!(!code.contains("ɵɵsetNgModuleScope"), "{code}"); + // A namespace member is in the `.d.ts`, under its dotted name; a class in + // a function is not. + let dts: Vec<_> = result.dts_declarations.iter().map(|d| d.class_name.as_str()).collect(); + assert_eq!(dts, ["NS.N", "NS.M", "Inner.Deep.S", "Inner.Deep.Mod"]); + + let partial = TransformOptions { + compilation_mode: CompilationMode::Partial, + ..TransformOptions::default() + }; + let code = transform_with(NAMESPACES, &partial).code; + assert_eq!(code.matches("ɵɵngDeclareClassMetadata(").count(), 1, "{code}"); + assert!(squash(&code).contains("ɵɵngDeclareClassMetadata({minVersion"), "{code}"); +} + +#[test] +fn compiles_a_namespace_member_without_hmr() { + // Its HMR initializer would go after the class, where ngtsc adds nothing. + let result = transform_with(NAMESPACES, &TransformOptions { hmr: true, ..Default::default() }); + let code = &result.code; + assert!(!code.contains("N_HmrLoad") && !code.contains("M_HmrLoad"), "{code}"); + assert!(code.contains("Q_HmrLoad"), "{code}"); + assert_eq!(result.template_updates.keys().collect::>(), ["test.ts@Q"]); + + let allocator = Allocator::default(); + let input = ComponentHmrUpdateInput { class_name: "N", ..Default::default() }; + let update = + compile_component_hmr_update(&allocator, "test.ts", NAMESPACES, &input, None, None); + assert!(update.module.is_none()); +} + +const NAMESPACE_SHADOWS: &str = r" +import { Component, Pipe } from '@angular/core'; +export namespace NS { + @Pipe({ name: 'p' }) + export class App { transform(v: any) { return v; } } +} +@Component({ selector: 'app', template: 'top' }) +export class App {} +export namespace A { + @Component({ selector: 'a-x', template: 'a' }) + export class X {} +} +export namespace B.C { + @Component({ selector: 'c-x', template: 'c' }) + export class X {} +} +"; + +#[test] +fn injects_a_namespace_member_into_its_own_dts_class() { + // ngtsc's `.d.ts` gives each class its own members, though `App` and `X` + // name two classes each. + let partial = + TransformOptions { compilation_mode: CompilationMode::Partial, ..Default::default() }; + let result = transform_with(NAMESPACE_SHADOWS, &partial); + let dts = "export declare namespace NS { + class App { + transform(v: any): any; + } +} +export declare class App { +} +export declare namespace A { + class X { + } +} +export declare namespace B.C { + class X { + } +} +"; + let declarations: Vec<_> = result + .dts_declarations + .into_iter() + .map(|d| DtsInjectDeclaration { + class_name: d.class_name, + members: d.members, + namespace_imports: d.namespace_imports.into_iter().collect(), + source_file: None, + }) + .collect(); + let out = inject_dts_declarations(dts, &declarations); + // Each top-level declaration of `out`, by its head. + let section = |head: &str| { + let section = out.split("export declare ").find(|s| s.starts_with(head)); + squash(section.unwrap_or_else(|| panic!("no {head:?} in\n{out}"))) + }; + assert_eq!(out.matches("static ɵfac").count(), 4, "{out}"); + let ns = section("namespace NS"); + assert!(ns.contains("ɵpipe") && !ns.contains("ɵcmp"), "{out}"); + let app = section("class App"); + assert!(app.contains("\"app\"") && !app.contains("ɵpipe"), "{out}"); + assert!(section("namespace A ").contains("\"a-x\""), "{out}"); + assert!(section("namespace B.C").contains("\"c-x\""), "{out}"); +} + +#[test] +fn injects_a_member_of_a_dotted_namespace_and_of_its_head_apart() { + // `A.C` is not the `C` of `namespace A.B`. + let source = r" +import { Component } from '@angular/core'; +export namespace A.B { + @Component({ selector: 'ab-c', template: 'ab' }) + export class C {} +} +export namespace A { + @Component({ selector: 'a-e', template: 'e' }) + export class C {} +} +"; + let partial = + TransformOptions { compilation_mode: CompilationMode::Partial, ..Default::default() }; + let result = transform_with(source, &partial); + let dts = "export declare namespace A.B { + class C { + } +} +export declare namespace A { + class C { + } +} +"; + let declarations: Vec<_> = result + .dts_declarations + .into_iter() + .map(|d| DtsInjectDeclaration { + class_name: d.class_name, + members: d.members, + namespace_imports: d.namespace_imports.into_iter().collect(), + source_file: None, + }) + .collect(); + let out = inject_dts_declarations(dts, &declarations); + let section = |head: &str| { + let section = out.split("export declare ").find(|s| s.starts_with(head)); + squash(section.unwrap_or_else(|| panic!("no {head:?} in\n{out}"))) + }; + assert_eq!(out.matches("static ɵfac").count(), 2, "{out}"); + let ab = section("namespace A.B"); + assert!(ab.contains("\"ab-c\"") && !ab.contains("\"a-e\""), "{out}"); + assert!(section("namespace A {").contains("\"a-e\""), "{out}"); +} + +#[test] +fn keeps_hmr_for_a_component_a_namespace_member_shadows() { + // A namespace member gets no HMR code, so a top-level class of its name + // does, as in ngtsc. + let result = + transform_with(NAMESPACE_SHADOWS, &TransformOptions { hmr: true, ..Default::default() }); + assert!(result.code.contains("App_HmrLoad"), "{}", result.code); + assert_eq!(result.template_updates.keys().collect::>(), ["test.ts@App"]); +} diff --git a/crates/oxc_angular_compiler/tests/partial_component_test.rs b/crates/oxc_angular_compiler/tests/partial_component_test.rs index d4e98a0ca..d187b0546 100644 --- a/crates/oxc_angular_compiler/tests/partial_component_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_component_test.rs @@ -352,6 +352,84 @@ export class FooComponent { ); } +/// ngtsc flattens and resolves `imports` before it lists `dependencies`: a +/// nested array, a namespace member, a spread and a `const` array named by +/// `imports` all reach it, once per class. `forwardRef` stays a forward +/// reference. +#[test] +fn partial_dependencies_list_classes_of_every_imports_entry() { + let allocator = Allocator::default(); + let source = "import { Component, forwardRef } from '@angular/core'; +import { ChildA, OtherB } from './children'; +import * as ns from './more'; + +const SHARED = [OtherB]; +const IMPORTS = [ChildA, [OtherB]]; + +@Component({ + selector: 'app-x', + template: '', + imports: [ChildA, [OtherB], ns.ChildB, ...SHARED, forwardRef(() => ChildC)], +}) +export class X {} + +@Component({ selector: 'app-y', template: '', imports: IMPORTS }) +export class Y {} + +@Component({ selector: 'child-c', template: 'c' }) +export class ChildC {} +"; + let code = compile_partial(&allocator, "x.component.ts", source); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + "dependencies:[{kind:\"directive\",type:ChildA,selector:\"*\"},\ + {kind:\"directive\",type:OtherB,selector:\"*\"},\ + {kind:\"directive\",type:ns.ChildB,selector:\"*\"},\ + {kind:\"directive\",type:i0.forwardRef(function(){returnChildC;}),selector:\"*\"}]" + ), + "got:\n{code}" + ); + assert!( + compact.contains( + "dependencies:[{kind:\"directive\",type:ChildA,selector:\"*\"},\ + {kind:\"directive\",type:OtherB,selector:\"*\"}]" + ), + "got:\n{code}" + ); + + let linked = link(&allocator, &code, "x.component.mjs"); + assert!(linked.linked, "linker should accept the partial output, emitted:\n{code}"); + let linked: String = linked.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(linked.contains("dependencies:()=>[ChildA,OtherB,ns.ChildB,ChildC]"), "got:\n{linked}"); + assert!(linked.contains("dependencies:[ChildA,OtherB]"), "got:\n{linked}"); +} + +/// `ns.ChildA` reads the export `ChildA` of the module `ChildA` is imported +/// from: ngtsc lists the class once, and a component matched twice on one +/// element throws NG0300 at runtime. +#[test] +fn partial_dependencies_list_a_named_and_a_namespace_import_of_one_export_once() { + let allocator = Allocator::default(); + let source = "import { Component } from '@angular/core'; +import { ChildA } from './a'; +import * as ns from './a'; +import * as other from './other'; + +@Component({ selector: 'app-x', template: '', imports: [ChildA, ns.ChildA, other.ChildA] }) +export class X {} +"; + let code = compile_partial(&allocator, "x.component.ts", source); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + "dependencies:[{kind:\"directive\",type:ChildA,selector:\"*\"},\ + {kind:\"directive\",type:other.ChildA,selector:\"*\"}]" + ), + "got:\n{code}" + ); +} + /// Sanity: a non-standalone component or a standalone component with no /// imports should NOT get a synthetic `dependencies` field. #[test] diff --git a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs index df5d3f258..a86d7f065 100644 --- a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs @@ -196,6 +196,87 @@ export class P extends Base { transform(v: any) { return v; } } ); } +#[test] +fn partial_use_class_keeps_empty_deps() { + // ngtsc: ɵɵngDeclareInjectable({..., useClass: Holder.cls(), deps: [] }), + // which links to `new (Holder.cls())()` rather than `Holder.cls().ɵfac`. + let (partial, linked) = partial_and_linked( + "import { Injectable } from '@angular/core'; +export const Holder: any = { cls: () => class {} }; +@Injectable({ providedIn: 'root', useClass: Holder.cls(), deps: [] }) +export class F1 {} +", + ); + assert!( + partial.contains("providedIn:\"root\",useClass:Holder.cls(),deps:[]})"), + "useClass should keep its empty deps, got:\n{partial}" + ); + assert!( + linked.contains("new(Holder.cls())()"), + "linked useClass should be instantiated, got:\n{linked}" + ); +} + +#[test] +fn partial_use_factory_keeps_empty_deps() { + // ngtsc: ɵɵngDeclareInjectable({..., useFactory: make, deps: [] }), + // which links to the conditional factory rather than `() => (make)()`. + let (partial, linked) = partial_and_linked( + "import { Injectable } from '@angular/core'; +export function make() { return {}; } +@Injectable({ providedIn: 'root', useFactory: make, deps: [] }) +export class F1 {} +", + ); + assert!( + partial.contains("providedIn:\"root\",useFactory:make,deps:[]})"), + "useFactory should keep its empty deps, got:\n{partial}" + ); + assert!( + linked.contains("__ngConditionalFactory__=(make)()"), + "linked useFactory should build the conditional factory, got:\n{linked}" + ); +} + +#[test] +fn partial_service_declares_service_and_factory() { + // ngtsc: ɵɵngDeclareFactory({..., deps: [], target: i0.ɵɵFactoryTarget.Service }) + // and ɵɵngDeclareService({ minVersion: "22.0.0", ..., type: S2, + // autoProvided: false }) / ({..., type: S3, factory: make }). + let (partial, linked) = partial_and_linked( + "import { Service } from '@angular/core'; +function make() { return new S3(); } +@Service({ autoProvided: false }) +export class S2 {} +@Service({ factory: make }) +export class S3 {} +", + ); + assert!( + partial.contains("type:S2,deps:[],target:i0.ɵɵFactoryTarget.Service})"), + "service factory should be a partial declaration, got:\n{partial}" + ); + assert!( + partial.contains( + "i0.ɵɵngDeclareService({minVersion:\"22.0.0\",version:\"0.0.0-PLACEHOLDER\",ngImport:i0,type:S2,autoProvided:false})" + ), + "autoProvided: false should be declared, got:\n{partial}" + ); + assert!( + partial.contains("ngImport:i0,type:S3,factory:make})"), + "the factory should be kept as written, got:\n{partial}" + ); + assert!(!partial.contains("ɵɵdefineService"), "no full definition expected:\n{partial}"); + assert!( + linked.contains("i0.ɵɵdefineService({token:S2,factory:S2.ɵfac,autoProvided:false})"), + "linked S2 should delegate to its ɵfac, got:\n{linked}" + ); + assert!( + linked.contains("i0.ɵɵdefineService({token:S3,factory:()=>(make)()})"), + "linked S3 should call the factory, got:\n{linked}" + ); +} + #[test] fn partial_injectable_factory_inherits_constructor() { // ngtsc: ɵɵngDeclareFactory({..., type: S, deps: null, diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap index 056fac8e6..67646134e 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap @@ -5,7 +5,6 @@ expression: result.code import { Component } from '@angular/core'; import * as i0 from '@angular/core'; - function TestComponent_li_0_Template(rf,ctx) { if ((rf & 1)) { i0.ɵɵdomElementStart(0,"li"); @@ -19,6 +18,7 @@ function TestComponent_li_0_Template(rf,ctx) { } } + export class TestComponent { items = ['a', 'b', 'c']; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap index d66f4fd61..5b0ca5e70 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap @@ -5,9 +5,9 @@ expression: result.code import { Component } from '@angular/core'; import * as i0 from '@angular/core'; - const _c0 = ["*"]; + export class BadgeComponent { static ɵfac = function BadgeComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || BadgeComponent)(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_without_imports_uses_dom_only_mode.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_without_imports_uses_dom_only_mode.snap index 06d0ed63f..f6be430fa 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_without_imports_uses_dom_only_mode.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_without_imports_uses_dom_only_mode.snap @@ -5,7 +5,6 @@ expression: result.code import { Component } from '@angular/core'; import * as i0 from '@angular/core'; - function ExternalComponent_For_5_Template(rf,ctx) { if ((rf & 1)) { i0.ɵɵdomElementStart(0,"li"); @@ -19,6 +18,7 @@ function ExternalComponent_For_5_Template(rf,ctx) { } } + export class ExternalComponent { title = 'External Component'; items = ['Apple', 'Banana', 'Cherry']; diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 4b6a907db..b9b942e4f 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -370,7 +370,7 @@ export interface DependencyMetadata { * that should be injected into the corresponding `.d.ts` class body. */ export interface DtsDeclaration { - /** The name of the class. */ + /** The name of the class; dotted (`NS.App`) for a `namespace` member. */ className: string /** * The static member declarations to add to the class body in `.d.ts`. @@ -388,7 +388,10 @@ export interface DtsDeclaration { /** One class's `.d.ts` declarations for [`inject_dts_declarations`]. */ export interface DtsInjectDeclaration { - /** The class the members belong to. */ + /** + * The class the members belong to; dotted (`NS.App`) for a class of a + * `namespace`. + */ className: string /** Newline-separated `static …;` member declarations. */ members: string diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index e1974d4a8..7c0a1c650 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -366,7 +366,7 @@ pub struct TemplateCompileResult { #[derive(Default)] #[napi(object)] pub struct DtsDeclaration { - /// The name of the class. + /// The name of the class; dotted (`NS.App`) for a `namespace` member. pub class_name: String, /// The static member declarations to add to the class body in `.d.ts`. /// Newline-separated `static` property declarations. @@ -453,7 +453,8 @@ pub struct TransformResult { #[derive(Default)] #[napi(object)] pub struct DtsInjectDeclaration { - /// The class the members belong to. + /// The class the members belong to; dotted (`NS.App`) for a class of a + /// `namespace`. pub class_name: String, /// Newline-separated `static …;` member declarations. pub members: String, diff --git a/napi/angular-compiler/test/hmr-component-update.test.ts b/napi/angular-compiler/test/hmr-component-update.test.ts index 2ac84f4f1..9b26d75e9 100644 --- a/napi/angular-compiler/test/hmr-component-update.test.ts +++ b/napi/angular-compiler/test/hmr-component-update.test.ts @@ -136,13 +136,13 @@ describe('compileComponentHmrUpdate', () => { const result = await expectUpdateMatchesTransform(code) // Closure message names are derived from the file path. They only show // in the Closure branch, which `$localize` skips at runtime. - const names = (code: string) => code.match(/MSG_\w+/g)?.sort() + const names = (code: string) => code.match(/MSG_[\w$]+/g)?.sort() const transformed = transformAngularFileSync(code, FILE, { hmr: true }) expect(names(result.hmrModule)).toEqual([ - 'MSG__APP_SRC_X_COMPONENT_TS__0', - 'MSG__APP_SRC_X_COMPONENT_TS__0', - 'MSG__APP_SRC_X_COMPONENT_TS__1', - 'MSG__APP_SRC_X_COMPONENT_TS__1', + 'MSG_EXTERNAL_3771704108176831903$$_APP_SRC_X_COMPONENT_TS_1', + 'MSG_EXTERNAL_3771704108176831903$$_APP_SRC_X_COMPONENT_TS_1', + 'MSG_EXTERNAL_5573521891741907372$$_APP_SRC_X_COMPONENT_TS_0', + 'MSG_EXTERNAL_5573521891741907372$$_APP_SRC_X_COMPONENT_TS_0', ]) expect(names(result.hmrModule)).toEqual(names(transformed.code)) diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index 163f22bd7..be21e6dc2 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -295,12 +295,12 @@ describe('linker: angularVersion', () => { angularVersion: { major: 22, minor: 2, patch: 1 }, }) - expect(result.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + expect(result.code).toContain('ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)') }) it('assumes the latest Angular when no version is given', () => { expect(linkAngularPackageSync(IMG_SRC_COMPONENT, id).code).toContain( - 'ɵɵproperty("src",ctx.url)', + 'ɵɵdomProperty("src",ctx.url)', ) }) @@ -310,7 +310,7 @@ describe('linker: angularVersion', () => { }) as any const result = await plugin.transform.handler.call({}, IMG_SRC_COMPONENT, id) - expect(result?.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + expect(result?.code).toContain('ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)') }) it('forwards the plugin angularVersion through the dependency optimizer', async () => { @@ -325,7 +325,7 @@ describe('linker: angularVersion', () => { id, ) - expect(code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + expect(code).toContain('ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)') }) it('keys the dependency optimizer cache on angularVersion', () => { @@ -356,9 +356,9 @@ describe('linker: angularVersion', () => { } expect(await link({ major: 22, minor: 2, patch: 1 })).toContain( - 'ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)', + 'ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)', ) - expect(await link({ major: 22, minor: 2, patch: 2 })).toContain('ɵɵproperty("src",ctx.url)') + expect(await link({ major: 22, minor: 2, patch: 2 })).toContain('ɵɵdomProperty("src",ctx.url)') }) })