From a5133ceaeb334fe5e87020a6d80e4ec4da3d493f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:30:10 +0800 Subject: [PATCH 01/37] fix(emitter): parenthesize a call or optional chain used as a new callee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A useClass with deps is instantiated as `new useClass(...deps)`, but the emitter printed the class expression bare. For `useClass: makeClass()` that gave `new makeClass()(dep)`, which constructs `makeClass` itself and throws "Class constructors cannot be invoked without 'new'"; for `useClass: Maybe?.cls` it gave `new Maybe?.cls(dep)`, a SyntaxError. ngtsc's `ts.factory.createNewExpression` runs the callee through `parenthesizeExpressionOfNew`, which wraps a callee whose left spine holds a call, an optional chain or a non-left-hand-side expression: `new (makeClass())(i0.ɵɵinject(DEP))`. Port that rule for Instantiate. Co-Authored-By: Claude Opus 5.5 --- .../src/output/emitter.rs | 25 +++++++++++++++ .../tests/integration_test.rs | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index fa96a2fb8..e6656112a 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(")"); @@ -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/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 096d8530a..69c863e09 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -16164,6 +16164,38 @@ 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_aot_service_decorator_version_gated() { // Targeting Angular < 22 should surface a diagnostic and leave the From 6e1d41dceb65e7300744d35d6acce9781127ff62 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:33:02 +0800 Subject: [PATCH 02/37] fix(injectable): parenthesize an optional-chain factory before calling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A factory written as an optional chain (`useFactory: Maybe?.make`, or `@Service({ factory: Maybe?.make })`, with or without deps) was called as `Maybe?.make()`, which joins the chain and silently returns undefined when `Maybe` is nullish. A `useClass: Maybe?.cls` without deps had the same problem: `Maybe?.cls.ɵfac(__ngFactoryType__)`. ngtsc calls the user factory through `ts.factory.createCallExpression`, whose `parenthesizeLeftSideOfAccess` wraps an optional chain: `(Maybe?.make)()`, which throws instead. Property access does the same, `(Maybe?.cls).ɵfac(__ngFactoryType__)`. Wrap such expressions the same way in the useFactory wrapper, the delegated factory, the service factory and the useClass delegation. Co-Authored-By: Claude Opus 5.5 --- .../src/factory/compiler.rs | 4 +- .../src/injectable/compiler.rs | 9 +-- crates/oxc_angular_compiler/src/output/ast.rs | 30 ++++++++++ .../src/service/compiler.rs | 4 +- .../tests/integration_test.rs | 58 +++++++++++++++++++ 5 files changed, 97 insertions(+), 8 deletions(-) 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/injectable/compiler.rs b/crates/oxc_angular_compiler/src/injectable/compiler.rs index e93373e14..760730c0d 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; @@ -286,14 +286,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 +351,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/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/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/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 69c863e09..33ebd4f9e 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -16196,6 +16196,64 @@ export class E4 {} } } +#[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_aot_service_decorator_version_gated() { // Targeting Angular < 22 should surface a diagnostic and leave the From 0b31b80ba6a28b90ffcfdc8c4119c46e8658d09f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:36:51 +0800 Subject: [PATCH 03/37] fix(injectable): keep an empty deps array on a useClass provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useClass: X, deps: []` was read as having no deps, so full output delegated to `X.ɵfac` and partial output dropped `deps: []`. A plain class (`useClass: Holder.cls()`) has no `ɵfac`, so it threw "Holder.cls(...).ɵfac is not a function" in both modes. ngtsc only checks whether the `deps` key is present: full output builds `new (Holder.cls())()` in the conditional factory, and partial output keeps `useClass: Holder.cls(), deps: []`. Track a missing `deps` key as `None` for useClass and build the delegated factory for an empty list. Co-Authored-By: Claude Opus 5.5 --- .../src/injectable/compiler.rs | 3 +- .../src/injectable/decorator.rs | 37 +++++++++---------- .../tests/integration_test.rs | 23 ++++++++++++ .../tests/partial_e2e_test.rs | 21 +++++++++++ 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/crates/oxc_angular_compiler/src/injectable/compiler.rs b/crates/oxc_angular_compiler/src/injectable/compiler.rs index 760730c0d..87889e8c5 100644 --- a/crates/oxc_angular_compiler/src/injectable/compiler.rs +++ b/crates/oxc_angular_compiler/src/injectable/compiler.rs @@ -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 diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index eed37edc5..ff216b98d 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. @@ -139,11 +140,7 @@ 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, @@ -413,7 +410,8 @@ fn extract_use_factory<'a>( if let Some(key_name) = get_property_key_name(&prop.key) { if key_name.as_str() == "useFactory" { let factory = convert_oxc_expression(allocator, &prop.value, source_text)?; - let deps = extract_deps_from_config(allocator, config_obj, source_text, consts); + let deps = extract_deps_from_config(allocator, config_obj, source_text, consts) + .unwrap_or_else(|| Vec::new_in(&allocator)); return Some(UseFactoryMetadata { factory, deps }); } } @@ -489,36 +487,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/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 33ebd4f9e..f02b5d881 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -16254,6 +16254,29 @@ export class C2 {} } } +#[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_aot_service_decorator_version_gated() { // Targeting Angular < 22 should surface a diagnostic and leave the diff --git a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs index df5d3f258..941865424 100644 --- a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs @@ -196,6 +196,27 @@ 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_injectable_factory_inherits_constructor() { // ngtsc: ɵɵngDeclareFactory({..., type: S, deps: null, From 9df626b6c0141f7fc751afa32d0972766c4c8de7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:28:51 +0800 Subject: [PATCH 04/37] fix(queries): keep empty parts of a string query predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A string predicate like `@ViewChild('x, ')` is split on `,` into its refs. The file transform dropped the empty parts (`["x"]`), and the linker did not split at all: it passed `["y, z"]` through as one ref. Both trimmed with Rust's white space set, which strips U+0085 and keeps U+FEFF. Angular's `getQueryPredicate` does `selector.split(',').map(t => t.trim())` for every string of the predicate, keeping empty parts: ngtsc 22.2.3 gives `["x", ""]`, and Angular's linker splits a partial `["y, z"]` into `["y", "z"]`. `String.prototype.trim` strips U+FEFF and keeps U+0085 (`["x"]` for `'x'`, `["y\u0085"]` for `'y\u0085'`). Do the same in both paths, for decorator and signal queries. Co-Authored-By: Claude Opus 5.5 --- .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../src/directive/query.rs | 33 +++++++----- crates/oxc_angular_compiler/src/linker/mod.rs | 34 +++++++++--- .../tests/integration_test.rs | 54 +++++++++++++++++++ .../oxc_angular_compiler/tests/linker_test.rs | 32 +++++++++++ 5 files changed, 136 insertions(+), 18 deletions(-) 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/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 255dcfff0..7bc119225 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -44,6 +44,7 @@ use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType, Span}; use crate::AngularVersion; +use crate::directive::split_query_predicate; use crate::optimizer::Edit; use crate::output::emitter::escape_string; use crate::pipeline::constant_pool::SharedPool; @@ -1596,17 +1597,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, or pass through as-is + query_selectors(selectors).map_or_else( + || source[expr.span().start as usize..expr.span().end as usize].to_string(), + |selectors| { + let selectors: Vec = + selectors.iter().map(|s| escape_string(s, false)).collect(); + format!("[{}]", selectors.join(", ")) + }, + ) } 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 @@ -1686,6 +1693,21 @@ 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`]). +fn query_selectors<'a>(selectors: &'a oxc_ast::ast::ArrayExpression<'a>) -> Option> { + let mut refs = Vec::new(); + for el in &selectors.elements { + let selector = as_static_string(el.as_expression()?)?; + refs.extend(split_query_predicate(selector)); + } + Some(refs) +} + /// Build the features array from component metadata. /// /// Examines boolean flags and providers to build the features array: diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index f02b5d881..a5a6906f1 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -7698,6 +7698,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 diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 5d16b48e2..9e22ada66 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -708,3 +708,35 @@ 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#"ɵɵcontentQuery(dirIndex,["y","z"],4)"#), "{}", result.code); + assert!(linked.contains(r#"ɵɵviewQuery(["x",""],5)"#), "{}", 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); +} From ad60a98cd410311b1b3583dcb5bef2f8952a7004 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:32:59 +0800 Subject: [PATCH 05/37] fix(linker): pool string query predicates as constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker emitted string query predicates inline, as `ɵɵviewQuery(["x"], 5)`, and started the host bindings' pool at `_c0`. Angular's linker compiles a declaration with `compileComponentFromMetadata` / `compileDirectiveFromMetadata` and one `ConstantPool`: `getQueryPredicate` pools each string predicate with `getConstLiteral(..., true)`, content queries first, then view queries, before the host bindings' constants and the template's, which reuse equal literals (`["*"]` for `ngContentSelectors`). A predicate with a lone surrogate (`["\uD800"]`) is split and pooled like any other. Pool them the same way, in that order. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 197 +++++++++++++----- .../oxc_angular_compiler/tests/linker_test.rs | 80 ++++++- 2 files changed, 227 insertions(+), 50 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 7bc119225..50ea3f75d 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -33,6 +33,8 @@ //! println!("{}", result.code); //! ``` +use std::borrow::Cow; + use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrayExpressionElement, BindingPattern, CallExpression, Decorator, @@ -46,10 +48,14 @@ use oxc_span::{GetSpan, SourceType, Span}; use crate::AngularVersion; use crate::directive::split_query_predicate; use crate::optimizer::Edit; +use crate::output::ast::{LiteralArrayExpr, LiteralExpr, LiteralValue, OutputExpression}; +use crate::output::emitter::JsEmitter; use crate::output::emitter::escape_string; -use crate::pipeline::constant_pool::SharedPool; +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::util::lone_surrogate::js_str_to_str; /// Check if an object property key needs quoting because it contains unsafe characters. /// @@ -1392,17 +1398,19 @@ fn link_directive( 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 allocator = Allocator::default(); + let LinkedQueries { + content_queries, + view_query, + mut declarations_js, + next_pool_index, + mut shared_pool, + } = link_queries(&allocator, meta, source, ns, type_name); + 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}")); } @@ -1431,7 +1439,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); @@ -1443,12 +1450,12 @@ 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(), + next_pool_index, + &mut shared_pool, ns, options.angular_version, ) { @@ -1456,24 +1463,21 @@ fn link_directive( 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() { + if 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}})()" - )) + // The IIFE only declares the query predicates and 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{declarations_js}\nreturn {define_call};\n}})()")) } } @@ -1569,6 +1573,7 @@ fn build_queries( ns: &str, type_name: &str, is_content_query: bool, + pool: &mut ConstantPool<'_>, ) -> Option { if queries.elements.is_empty() { return None; @@ -1598,13 +1603,13 @@ fn build_queries( let predicate = if let Some(expr) = get_property_expression(query_obj.as_ref(), "predicate") { if let Expression::ArrayExpression(selectors) = expr { - // String array predicate — split into its refs, or pass through as-is + // 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| { - let selectors: Vec = - selectors.iter().map(|s| escape_string(s, false)).collect(); - format!("[{}]", selectors.join(", ")) + JsEmitter::new().emit_expression(&pool_query_selectors(pool, &selectors)) }, ) } else { @@ -1698,16 +1703,106 @@ fn build_queries( /// /// 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`]). -fn query_selectors<'a>(selectors: &'a oxc_ast::ast::ArrayExpression<'a>) -> Option> { +/// `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 = as_static_string(el.as_expression()?)?; - refs.extend(split_query_predicate(selector)); + 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 the refs of a string array query predicate as one constant, as +/// Angular's `getQueryPredicate` does with `getConstLiteral(..., true)`. +fn pool_query_selectors<'a>( + pool: &mut ConstantPool<'a>, + selectors: &[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(selectors.len(), &allocator); + for selector in selectors { + entries.push(OutputExpression::Literal(oxc_allocator::Box::new_in( + LiteralExpr { + value: LiteralValue::String(Ident::from_in(selector.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<'a> { + /// 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, + /// The next constant index after the predicates. + next_pool_index: u32, + /// The pooled predicates, for the host bindings and the template to reuse. + shared_pool: SharedPool<'a>, +} + +/// 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, + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> LinkedQueries<'a> { + let mut pool = ConstantPool::new(allocator); + let content_queries = get_array_property(meta, "queries") + .and_then(|queries| build_queries(queries, source, ns, type_name, true, &mut pool)); + let view_query = get_array_property(meta, "viewQueries") + .and_then(|queries| build_queries(queries, source, ns, type_name, false, &mut pool)); + + let emitter = JsEmitter::with_core_namespace(ns); + let mut declarations_js = String::new(); + for decl in &declare_pooled_constants_from(&mut pool, 0) { + declarations_js.push_str(&emitter.emit_statement(decl)); + declarations_js.push('\n'); + } + + LinkedQueries { + content_queries, + view_query, + declarations_js, + next_pool_index: pool.next_name_index(), + shared_pool: pool.take_shared(), + } +} + /// Build the features array from component metadata. /// /// Examines boolean flags and providers to build the features array: @@ -1816,11 +1911,18 @@ 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, + next_pool_index: query_next_pool_index, + mut shared_pool, + } = link_queries(&template_allocator, meta, source, ns, type_name); 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. @@ -1829,7 +1931,7 @@ fn link_component( &extract_host_metadata_input(host_obj), type_name, get_string_property(meta, "selector"), - 0, + query_next_pool_index, &mut shared_pool, ns, options.angular_version, @@ -1845,7 +1947,9 @@ fn link_component( ns, options.angular_version, &defer_block_deps, - host_output.as_ref().map_or(0, |host_output| host_output.next_pool_index), + host_output + .as_ref() + .map_or(query_next_pool_index, |host_output| host_output.next_pool_index), shared_pool, ) .ok()?; @@ -1863,16 +1967,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}")); } @@ -2131,9 +2231,10 @@ 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; + // Wrap in IIFE with query, host binding and template declarations, in the + // order they were pooled + let mut declarations = query_declarations_js; + declarations.push_str(&host_binding_declarations_js); declarations.push_str(&template_output.declarations_js); if declarations.trim().is_empty() { Some(define_component) diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 9e22ada66..86846ae35 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -722,8 +722,84 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", n 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#"ɵɵcontentQuery(dirIndex,["y","z"],4)"#), "{}", result.code); - assert!(linked.contains(r#"ɵɵviewQuery(["x",""],5)"#), "{}", result.code); + 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);", + "i0.ɵɵviewQuery(TemplateRef,5);", + "i0.ɵɵviewQuery(_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);", + "i0.ɵɵcontentQuery(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);", + "i0.ɵɵviewQuery(_c1,5);", + "i0.ɵɵviewQuery(_c2,5);", + "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c3));", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } } #[test] From 97694fa23164c5f41ec3824ba270baa211d7fb69 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:36:17 +0800 Subject: [PATCH 06/37] fix(linker): emit the selector attrs field before Angular v22 The linker never emitted `attrs`, whatever `angularVersion` was. The file transform does since 1a577e6, for versions below 22.0.0-next.4. Angular 21's linker runs `compileComponentFromMetadata`, which pools the first selector's attributes after `baseDirectiveFields` (the query predicates and the host bindings' constants) and before the template, and sets `attrs` after `features`: a `span[bitBadge]` component linked with 21.2.25 gives `const _c0 = ["bitBadge", ""]; ... attrs: _c0, ngContentSelectors: _c1`. Do the same when `angularVersion` asks for it. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 78 +++++++++++++------ .../oxc_angular_compiler/tests/linker_test.rs | 51 ++++++++++++ 2 files changed, 106 insertions(+), 23 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 50ea3f75d..ce1cdbc97 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -54,7 +54,7 @@ use crate::output::emitter::escape_string; 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. @@ -1609,7 +1609,7 @@ fn build_queries( 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_query_selectors(pool, &selectors)) + JsEmitter::new().emit_expression(&pool_string_array(pool, &selectors)) }, ) } else { @@ -1730,21 +1730,23 @@ fn query_selectors<'a>( Some(refs) } -/// Pool the refs of a string array query predicate as one constant, as -/// Angular's `getQueryPredicate` does with `getConstLiteral(..., true)`. -fn pool_query_selectors<'a>( +/// 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>, - selectors: &[impl AsRef], + 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(selectors.len(), &allocator); - for selector in selectors { + 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(selector.as_ref(), &allocator)), + value: LiteralValue::String(Ident::from_in(value.as_ref(), allocator)), source_span: None, }, &allocator, @@ -1787,22 +1789,26 @@ fn link_queries<'a>( let view_query = get_array_property(meta, "viewQueries") .and_then(|queries| build_queries(queries, source, ns, type_name, false, &mut pool)); - let emitter = JsEmitter::with_core_namespace(ns); - let mut declarations_js = String::new(); - for decl in &declare_pooled_constants_from(&mut pool, 0) { - declarations_js.push_str(&emitter.emit_statement(decl)); - declarations_js.push('\n'); - } - LinkedQueries { content_queries, view_query, - declarations_js, + declarations_js: declare_pooled_constants_js(&mut pool, ns), next_pool_index: pool.next_name_index(), shared_pool: pool.take_shared(), } } +/// 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: @@ -1938,6 +1944,28 @@ fn link_component( ) }); + // 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 = ConstantPool::with_starting_index( + &template_allocator, + host_output + .as_ref() + .map_or(query_next_pool_index, |host_output| host_output.next_pool_index), + ); + attrs_pool.inherit_shared(shared_pool); + 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); + let template_output = crate::component::compile_template_for_linker( &template_allocator, template_ref, @@ -1947,10 +1975,8 @@ fn link_component( ns, options.angular_version, &defer_block_deps, - host_output - .as_ref() - .map_or(query_next_pool_index, |host_output| host_output.next_pool_index), - shared_pool, + attrs_pool.next_name_index(), + attrs_pool.take_shared(), ) .ok()?; @@ -2029,6 +2055,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}")); @@ -2231,10 +2262,11 @@ fn link_component( let define_component = format!("/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineComponent({{ {} }})", parts.join(", ")); - // Wrap in IIFE with query, host binding and template declarations, in the - // order they were pooled + // Wrap in IIFE with query, host binding, attrs and template 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) diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 86846ae35..ba43819c0 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -816,3 +816,54 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", n assert!(result.code.contains(r#"["x"]"#), "{}", result.code); assert!(result.code.contains("[\"y\u{85}\"]"), "{}", result.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}"); +} From 40e4bd1d954fe30413da1930b1bc955b0b4b5489 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:31:35 +0800 Subject: [PATCH 07/37] fix(i18n): count message names across the file with the pool Each component numbered its i18n_N and MSG_ names from its own index, so the second component of a file started again at i18n_0 and MSG_..._0, and the Closure names had an extra underscore after the file suffix (MSG_TEST_TS__0). ngtsc claims both names with pool.uniqueName, so they run on across the file: i18n_ counts on its own, the external Closure name counts the file suffix (MSG_EXTERNAL_$$TEST_TS_0), and the other one counts MSG_ (MSG_TEST_TS_0). Co-Authored-By: Claude Opus 5.5 --- .../src/pipeline/phases/i18n_closure.rs | 40 +++++++++++------- .../pipeline/phases/i18n_const_collection.rs | 17 ++++---- .../tests/const_pool_file_wide_test.rs | 42 +++++++++++++++++++ .../test/hmr-component-update.test.ts | 8 ++-- 4 files changed, 77 insertions(+), 30 deletions(-) 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/tests/const_pool_file_wide_test.rs b/crates/oxc_angular_compiler/tests/const_pool_file_wide_test.rs index 95f4d5df0..1a77eae30 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 @@ -271,3 +271,45 @@ 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"] + ); +} diff --git a/napi/angular-compiler/test/hmr-component-update.test.ts b/napi/angular-compiler/test/hmr-component-update.test.ts index 2ac84f4f1..79f31a20c 100644 --- a/napi/angular-compiler/test/hmr-component-update.test.ts +++ b/napi/angular-compiler/test/hmr-component-update.test.ts @@ -139,10 +139,10 @@ describe('compileComponentHmrUpdate', () => { 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__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', ]) expect(names(result.hmrModule)).toEqual(names(transformed.code)) From 2cd188082649af99df7152f7245653987ec50976 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:38:26 +0800 Subject: [PATCH 08/37] fix(emit): declare pool values and shared functions in pooled order A template declared all its pooled values first and then the pool's statements, so a DepsFn pooled before a pure function was declared after it: _c0, A_Defer_1_DepsFn, _forTrack0. ngtsc's ConstantPool.statements is one list in insertion order, which gives A_Defer_1_DepsFn, _c0, _forTrack0. The pool now records how many statements came before each value, and emit interleaves them. Co-Authored-By: Claude Opus 5.5 --- .../src/pipeline/constant_pool.rs | 28 +++++++-- .../oxc_angular_compiler/src/pipeline/emit.rs | 57 ++++++++----------- .../tests/defer_implicit_imports_test.rs | 30 ++++++++++ 3 files changed, 77 insertions(+), 38 deletions(-) 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/tests/defer_implicit_imports_test.rs b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs index d72508117..fddba38a8 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -1690,3 +1690,33 @@ 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}" + ); +} From 15db20d7fdcbfde7cb4060286e3ec37e10128e93 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:42:31 +0800 Subject: [PATCH 09/37] fix(compile-template): compile host bindings before the template compileTemplate (compile_template_to_js_with_options) compiled the template first and the host bindings after, so the template's constants took the first _c names and a host constant equal to a template one reused the template's. The file transform and the linker already do host first. ngtsc's compileComponentFromMetadata compiles the host bindings (baseDirectiveFields) before the template with one pool: the host bindings' constants come first, and the template reuses equal ones. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 81 ++++++++++--------- .../tests/const_pool_file_wide_test.rs | 30 ++++++- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 9c32b3cd0..311d3c134 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6889,6 +6889,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 +6929,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 +6958,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 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 1a77eae30..2af3b17fe 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#" From 074fe0dce153ddc220b8b4ec4803511b69ffcaab Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:28:40 +0800 Subject: [PATCH 10/37] fix(partial): lazy-load @defer dependencies in partial declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial compilation returned before the `@defer` step, so an `imports` entry used only inside `@defer` kept its static import and an eager `dependencies` entry, and the declaration had no `deferBlockDependencies`. ngtsc's `compilePartial` resolves the blocks as its full compilation does: the entry leaves `dependencies`, its static import is removed, `ɵɵngDeclareComponent` lists one dependency function per block in `deferBlockDependencies` (null for a block without dependencies), and `ɵɵngDeclareClassMetadataAsync` takes the lazy class as a `resolveMetadata` parameter. The `@defer` step now runs for both modes. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 196 +++++++++++------- .../src/partial/component.rs | 39 ++-- .../tests/defer_implicit_imports_test.rs | 88 ++++++++ 3 files changed, 233 insertions(+), 90 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 311d3c134..2ab04d05e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5534,6 +5534,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 +5543,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, @@ -5828,6 +5830,97 @@ 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())); + 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 +6008,38 @@ 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( + let (defer_block_dependencies, implicitly_deferred_imports) = + resolve_implicit_defer(metadata, &r3_result.nodes, options, import_selector, can_defer); + 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, + &metadata.import_entry_classes, + 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 +6126,9 @@ 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 (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`. diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 1c4e4a052..6da68a030 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -69,6 +69,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. @@ -345,19 +349,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) } 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 fddba38a8..c5b0fdd5a 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -1720,3 +1720,91 @@ export class A { k = 1; xs: any[] = []; } "{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}"); +} From a0ddf14963c8944ae3af4a584cb0c86a824d19cc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:31:34 +0800 Subject: [PATCH 11/37] fix(defer): lazy-load an imports entry next to a non-class entry An `imports` entry that doesn't name a class, a spread (`imports: [ChildA, ...SHARED]`), a nested array (`[ChildA, [OtherB]]`) or a namespace member (`[ChildA, ns.ChildB]`), made the `@defer` step give up on the whole component, so ChildA, used only inside `@defer`, stayed an eager dependency and kept its static import, in that component and in any other of the file that defers it. ngtsc's `registerDeferrableCandidate` only skips such an entry, which is not an identifier: ChildA is still lazy-loaded, and the classes the entry brings in stay eager, even when one of them is the deferred class. These entries are now skipped the same way. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 10 +- .../tests/defer_implicit_imports_test.rs | 134 ++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 2ab04d05e..92494b135 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5696,7 +5696,10 @@ 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` isn't an array literal. An entry that +/// doesn't name a class (a spread, a nested array, `ns.Cmp`) is never a +/// candidate, as ngc's `registerDeferrableCandidate` skips anything but an +/// identifier, and the classes it brings in stay eager. fn implicit_defer_candidates<'a>( metadata: &ComponentMetadata<'a>, nodes: &[crate::ast::r3::R3Node<'a>], @@ -5706,11 +5709,6 @@ fn implicit_defer_candidates<'a>( 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) { return None; 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 c5b0fdd5a..488f3df3b 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,140 @@ 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 forward_ref_not_from_angular_core_stays_eager() { let dir = TempDir::new().unwrap(); From 85da8a6256b47321e7e06a29cf03fef1e9af4e19 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:49:39 +0800 Subject: [PATCH 12/37] fix(transform): compile decorated classes nested in functions and blocks The transform only walked top-level statements, so a @Component, @Directive, @Pipe, @Injectable or @NgModule class declared in a function body, block, arrow or IIFE kept its `__decorate` call and needed the JIT compiler at runtime. ngtsc compiles these classes like top-level ones: the definitions go on the class, `setClassMetadata` / `setClassDebugInfo` / the HMR initializer right after it in the same scope, and the constant pool and child template functions at the file top level, after the imports, shared with the file's other classes (the function may run before its own statement). A class declared in another class's members compiles before that class. Now the transform does the same, keys class definitions by span (two functions may declare classes of one name), keeps nested classes out of the .d.ts, and finds them for import elision, resource URLs and HMR updates. A nested class whose metadata or constructor parameter types read a binding of the enclosing function keeps its decorators: the evaluator and import map only see top-level bindings, and would otherwise miss a local const or use the binding a local one shadows. Co-Authored-By: Claude Opus 5.5 --- .../src/component/import_elision.rs | 63 +-- .../src/component/transform.rs | 427 +++++++++++------- crates/oxc_angular_compiler/src/util/mod.rs | 67 +++ .../tests/nested_class_test.rs | 335 ++++++++++++++ 4 files changed, 679 insertions(+), 213 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/nested_class_test.rs 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/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 92494b135..c4dfafbf9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1644,6 +1644,109 @@ 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() + { + 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 +3313,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, effective_start, class_body_end) + let mut class_positions: Vec<(Span, u32, u32)> = Vec::new(); + // The compiled classes nested in a function or block, by span: the + // file-level declarations of their compile go after the imports. + let mut nested_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 +3380,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. @@ -3483,24 +3588,26 @@ 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); + 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)); + if !keeps_decorators { + if site.nested { + nested_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 @@ -4099,18 +4206,18 @@ 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, + class.span, compute_effective_start( class, &decorator_spans_to_remove, @@ -4131,15 +4238,18 @@ 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, - )); + // A nested class is not in the `.d.ts`. + if !site.nested { + 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, + )); + } result.component_count += 1; } @@ -4291,12 +4401,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 !site.nested { + result.dts_declarations.push(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. @@ -4312,7 +4424,7 @@ pub fn transform_angular_file( ); class_positions.push(( - class_name.clone(), + class.span, compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); @@ -4326,7 +4438,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 +4528,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 !site.nested { + result.dts_declarations.push(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( @@ -4436,12 +4550,12 @@ pub fn transform_angular_file( ); class_positions.push(( - class_name.clone(), + class.span, compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4544,12 +4658,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 !site.nested { + result.dts_declarations.push(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) @@ -4574,12 +4690,12 @@ pub fn transform_angular_file( // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push(( - class_name.clone(), + class.span, compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), external_decls), ); } @@ -4651,11 +4767,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 !site.nested { + result.dts_declarations.push(dts::generate_service_dts( + dts_core_namespace.as_str(), + &service_metadata, + type_argument_count, + )); + } let decls_after_class = build_set_class_metadata_decls( &allocator, @@ -4669,12 +4787,12 @@ pub fn transform_angular_file( ); class_positions.push(( - class_name.clone(), + class.span, compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4735,11 +4853,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 !site.nested { + result.dts_declarations.push(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( @@ -4754,12 +4874,12 @@ pub fn transform_angular_file( ); class_positions.push(( - class_name.clone(), + class.span, compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); class_definitions.insert( - class_name, + class.span, (property_assignments, String::new(), decls_after_class), ); } @@ -4770,24 +4890,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 +4993,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 a nested 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 +5054,14 @@ 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. A nested class's constants go + // after the imports, like ngtsc's (a function declared later in the file + // may run before its statement), so the declarations of each position are + // joined into one edit, in the order the classes compiled. + let mut decls_by_start: Vec<(u32, String)> = Vec::new(); + for (class_span, effective_start, 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() { @@ -4962,7 +5073,16 @@ pub fn transform_angular_file( // Constants/child views before the class if !decls_before_class.is_empty() { - edits.push(Edit::insert(*effective_start, format!("{}\n", decls_before_class))); + let start = if nested_compiled.contains(class_span) { + after_imports + } else { + *effective_start + }; + let decls = format!("{decls_before_class}\n"); + match decls_by_start.iter_mut().find(|(other, _)| *other == start) { + Some((_, joined)) => joined.push_str(&decls), + None => decls_by_start.push((start, decls)), + } } // Debug info/class metadata/HMR after the class @@ -4971,6 +5091,7 @@ pub fn transform_angular_file( } } } + edits.extend(decls_by_start.into_iter().map(|(start, decls)| Edit::insert(start, decls))); // 5e. TDZ-safe hoisting of top-level bindings referenced by decorator // metadata but declared after the decorated class. Without this, the @@ -5226,22 +5347,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,19 +5514,10 @@ 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, - }; + // A nested class too: the transform compiles those, with an HMR + // initializer like ngtsc's. + let Some(class) = crate::util::declared_classes(program).into_iter().find_map(|declared| { + let class = declared.class; class.id.as_ref().is_some_and(|id| id.name == class_name).then_some(class) }) else { return update; @@ -6602,8 +6705,8 @@ fn cmp_definition_has_inline_long_style(cmp_definition: &OutputExpression<'_>) - 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(); @@ -6619,7 +6722,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 { @@ -6631,10 +6734,10 @@ 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 { diff --git a/crates/oxc_angular_compiler/src/util/mod.rs b/crates/oxc_angular_compiler/src/util/mod.rs index 5d099dc53..4feb7c401 100644 --- a/crates/oxc_angular_compiler/src/util/mod.rs +++ b/crates/oxc_angular_compiler/src/util/mod.rs @@ -34,3 +34,70 @@ 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 or block rather than at the top level. + /// ngtsc compiles these too, and hoists their constants to the file top. + pub nested: bool, +} + +/// 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 `namespace` 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; + + struct Nested<'a>(std::vec::Vec<*const Class<'a>>); + 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 { + self.0.push(class); + } + } + fn visit_ts_module_block(&mut self, _: &oxc_ast::ast::TSModuleBlock<'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(std::vec::Vec::new()); + match class { + Some(class) => oxc_ast_visit::walk::walk_class(&mut nested, class), + None => nested.visit_statement(stmt), + } + for class in nested.0 { + // 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 }); + } + if let Some(class) = class { + classes.push(DeclaredClass { class, top_level: stmt, nested: false }); + } + } + classes +} 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..87eef1db5 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -0,0 +1,335 @@ +//! 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, ResolvedResources, TransformOptions, TransformResult, + compile_component_hmr_update, extract_component_resource_urls, 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 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); +} From 925860d6eaf5ee788228fab92d136f70104f4990 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 15:18:25 +0800 Subject: [PATCH 13/37] fix(transform): hoist constants a nested class shares with an earlier class A nested class can reuse a constant an earlier top-level class pooled, which was declared before that earlier class. A function declared later in the file can run before that statement, so the nested class read the constant in its TDZ (ReferenceError). Declare the constants of every class compiled before the last nested one after the imports too, where ngtsc declares them all. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 10 +++++--- .../tests/nested_class_test.rs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c4dfafbf9..1973c7805 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5057,9 +5057,13 @@ pub fn transform_angular_file( // 5d. Class definition insertion edits. A nested class's constants go // after the imports, like ngtsc's (a function declared later in the file // may run before its statement), so the declarations of each position are - // joined into one edit, in the order the classes compiled. + // joined into one edit, in the order the classes compiled. So do those of + // every class compiled before the last nested one: the nested class may + // reuse a constant one of them pooled. + let last_nested = class_positions.iter().rposition(|(span, ..)| nested_compiled.contains(span)); let mut decls_by_start: Vec<(u32, String)> = Vec::new(); - for (class_span, effective_start, class_body_end) in &class_positions { + for (index, (class_span, effective_start, class_body_end)) in class_positions.iter().enumerate() + { if let Some((property_assignments, decls_before_class, decls_after_class)) = class_definitions.get(class_span) { @@ -5073,7 +5077,7 @@ pub fn transform_angular_file( // Constants/child views before the class if !decls_before_class.is_empty() { - let start = if nested_compiled.contains(class_span) { + let start = if last_nested.is_some_and(|last| index <= last) { after_imports } else { *effective_start diff --git a/crates/oxc_angular_compiler/tests/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs index 87eef1db5..c71c8915a 100644 --- a/crates/oxc_angular_compiler/tests/nested_class_test.rs +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -238,6 +238,29 @@ function make() { 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( From 06b1fb0003b1996616eeddda5f3453689e9c880f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 15:35:04 +0800 Subject: [PATCH 14/37] fix(defer): count nested components' deferrable imports across the file The count of other components' deferrable `imports` entries walked only top-level classes. A nested component that defers an import counted the top-level ones and removed the static import, while the top-level component missed the nested mark and kept an eager reference: ReferenceError when the module loads. Two nested components deferring one import both stayed eager. Walk every class the transform compiles, as ngc's DeferredSymbolTracker sees every component of the file. A nested class that keeps its decorators (it reads an enclosing binding) keeps its `imports` entry and is skipped. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 35 +++--- .../tests/defer_implicit_imports_test.rs | 115 ++++++++++++++++++ 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 1973c7805..272eaeb68 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3862,10 +3862,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( @@ -5845,23 +5855,6 @@ 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 `imports` entries another component of the file marks as deferrable /// candidates ([`DeferCandidates::marked`]); empty for any other class. Its /// template is parsed, as `compile_component_full` does, only when it may 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 488f3df3b..76add8d87 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -1942,3 +1942,118 @@ export class Parent {} ); 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}"); +} From 713bf03a71dde471b3ac903dfcabd913224413bd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 15:46:33 +0800 Subject: [PATCH 15/37] fix(hmr): compile same-named nested components without HMR HMR finds a component by {path}@{name}. Two classes named Tp in different functions shared that id, so both listeners took one update, and the update compiler always picked the first Tp: changing the second component's template could install the first's on both. ngtsc builds the same id for both. Compile a class whose name another class of the file declares without HMR (no initializer, no template update), and give it no HMR update. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 32 +++++++++++++++++-- .../tests/nested_class_test.rs | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 272eaeb68..15cfa105b 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3593,6 +3593,10 @@ pub fn transform_angular_file( // 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; @@ -3906,6 +3910,11 @@ pub fn transform_angular_file( #[cfg(not(feature = "cross_file_elision"))] let (import_selector, can_defer) = (None, None); + let options = match &hmr_options { + Some(no_hmr) if 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 // Pass the file-level namespace registry to ensure consistent namespace assignments @@ -5529,8 +5538,12 @@ pub fn compile_component_hmr_update( update.fingerprint = Some(fingerprint); // A nested class too: the transform compiles those, with an HMR - // initializer like ngtsc's. - let Some(class) = crate::util::declared_classes(program).into_iter().find_map(|declared| { + // initializer like ngtsc's, unless another class has its name. + 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; class.id.as_ref().is_some_and(|id| id.name == class_name).then_some(class) }) else { @@ -5855,6 +5868,21 @@ fn implicit_defer_candidates<'a>( Some(DeferCandidates { candidates, block_only, marked }) } +/// The class names two or more classes of the file declare (in different +/// functions or blocks). +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_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 /// candidates ([`DeferCandidates::marked`]); empty for any other class. Its /// template is parsed, as `compile_component_full` does, only when it may diff --git a/crates/oxc_angular_compiler/tests/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs index c71c8915a..fbb103d5f 100644 --- a/crates/oxc_angular_compiler/tests/nested_class_test.rs +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -356,3 +356,35 @@ export function f() { 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()); +} From f4d0a67980a3134900482423d33f45d97975d0b8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 15:59:31 +0800 Subject: [PATCH 16/37] fix(transform): keep a nested class calling a shadowing initializer API The guard that keeps a nested class's decorators when its metadata reads an enclosing binding visited only the arguments of a field initializer call. With `function make(input) { class C { value = input(0); } }` the callee resolved to the imported `input` by name, so C got a signal input ngtsc doesn't see. Visit the callee too. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 3 +++ .../tests/nested_class_test.rs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 15cfa105b..31684a77f 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1732,6 +1732,9 @@ fn names_enclosing_binding( 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); } } diff --git a/crates/oxc_angular_compiler/tests/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs index fbb103d5f..6dc670afc 100644 --- a/crates/oxc_angular_compiler/tests/nested_class_test.rs +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -388,3 +388,26 @@ export class Other {} 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}"); +} From ab23f4e68490d397614fc4c2e7a418a9aa58d444 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:28:24 +0800 Subject: [PATCH 17/37] =?UTF-8?q?fix(linker):=20parenthesize=20a=20non-pla?= =?UTF-8?q?in=20useClass=20before=20.=C9=B5fac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker built the useClass factory as the text `{useClass}.ɵfac(...)`. A conditional useClass then read `flag ? A : B.ɵfac(...)`, and an optional chain read `Maybe?.cls.ɵfac(...)`, pulling .ɵfac into the chain. Angular's linker builds this as an AST, so TS's parenthesizeLeftSideOfAccess wraps any receiver that is not a plain left-hand side or is an optional chain: `(flag ? A : B).ɵfac(...)`, `(Maybe?.cls).ɵfac(...)`. The other string-built templates already wrap their operands (`new (X)(...)`, `(f)(...)`). Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 54 ++++++++++++++++++- .../src/output/emitter.rs | 2 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index ce1cdbc97..cc1e6fdd5 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -51,6 +51,7 @@ use crate::optimizer::Edit; use crate::output::ast::{LiteralArrayExpr, LiteralExpr, LiteralValue, OutputExpression}; use crate::output::emitter::JsEmitter; use crate::output::emitter::escape_string; +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; @@ -912,6 +913,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. @@ -960,7 +977,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} }})" )); @@ -2504,6 +2522,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"). diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index e6656112a..b198149b8 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -1208,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; From 3a6b1a35d367af3c7b6cebfe698b0b073fa10e55 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:30:24 +0800 Subject: [PATCH 18/37] fix(injectable): keep an empty deps array on a useFactory provider `useFactory: make, deps: []` was read as having no deps, so full output wrapped it as `() => make()` and partial output dropped `deps: []`. ngtsc only checks whether the `deps` key is present, as for useClass: full output builds the delegated conditional factory, and partial output keeps `useFactory: make, deps: []`, which the linkers turn into the same conditional factory. Track a missing `deps` key as `None` for useFactory. Co-Authored-By: Claude Opus 5.5 --- .../src/injectable/compiler.rs | 3 ++- .../src/injectable/decorator.rs | 14 +++++-------- .../tests/integration_test.rs | 19 +++++++++++++++++ .../tests/partial_e2e_test.rs | 21 +++++++++++++++++++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/oxc_angular_compiler/src/injectable/compiler.rs b/crates/oxc_angular_compiler/src/injectable/compiler.rs index 87889e8c5..216ebcb37 100644 --- a/crates/oxc_angular_compiler/src/injectable/compiler.rs +++ b/crates/oxc_angular_compiler/src/injectable/compiler.rs @@ -134,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 diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index ff216b98d..60867a9a4 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -76,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. @@ -147,11 +148,7 @@ impl<'a> InjectableMetadata<'a> { 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); } @@ -410,8 +407,7 @@ fn extract_use_factory<'a>( if let Some(key_name) = get_property_key_name(&prop.key) { if key_name.as_str() == "useFactory" { let factory = convert_oxc_expression(allocator, &prop.value, source_text)?; - let deps = extract_deps_from_config(allocator, config_obj, source_text, consts) - .unwrap_or_else(|| Vec::new_in(&allocator)); + let deps = extract_deps_from_config(allocator, config_obj, source_text, consts); return Some(UseFactoryMetadata { factory, deps }); } } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index a5a6906f1..dac7f6c48 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -16331,6 +16331,25 @@ export class F2 {} 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 diff --git a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs index 941865424..fceadc5e5 100644 --- a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs @@ -217,6 +217,27 @@ export class F1 {} ); } +#[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_injectable_factory_inherits_constructor() { // ngtsc: ɵɵngDeclareFactory({..., type: S, deps: null, From 5f045d8b6dcf3758f46ee13435bb13be78502086 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:34:18 +0800 Subject: [PATCH 19/37] fix(service): emit partial declarations for @Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In partial mode a @Service class got a full ɵɵdefineService and a plain ɵfac function, so a library build shipped already-linked code. ngtsc's ServiceDecoratorHandler.compilePartial emits ɵɵngDeclareFactory({..., deps: [], target: ɵɵFactoryTarget.Service}) and ɵɵngDeclareService({minVersion: "22.0.0", ..., type, autoProvided?: false, factory?}), keeping the user factory as written. Emit the same; the linker already links ɵɵngDeclareService. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 1 + .../src/partial/factory.rs | 3 +- .../oxc_angular_compiler/src/partial/mod.rs | 4 + .../src/partial/service.rs | 126 ++++++++++++++++++ .../src/r3/identifiers.rs | 3 + .../src/service/definition.rs | 48 +++++-- .../tests/partial_e2e_test.rs | 39 ++++++ 7 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/partial/service.rs diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 31684a77f..f6b7c589d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4778,6 +4778,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(); 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/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/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/tests/partial_e2e_test.rs b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs index fceadc5e5..a86d7f065 100644 --- a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs @@ -238,6 +238,45 @@ export class F1 {} ); } +#[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, From 22dc6e8245c6218d81e959416758c89133a573d2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:27:23 +0800 Subject: [PATCH 20/37] fix(hmr): read nested components' template and styles in the fingerprint The HMR fingerprint only looked at top-level classes, so the inline template and styles of a component declared in a function stayed in it. An edit to them changed the fingerprint and the vite plugin reloaded the page, though the transform compiles that component with HMR (as ngtsc does) and an update would do. Walk every class the transform compiles, nested ones too, and leave out a name two classes declare, which compiles without HMR. Co-Authored-By: Claude Opus 5.5 --- .../src/component/hmr_fingerprint.rs | 29 ++++--------- .../src/component/transform.rs | 2 +- .../tests/hmr_fingerprint_test.rs | 43 ++++++++++++++++++- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs index c7e212506..3eb128b3d 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,14 @@ 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. + 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 ambiguous.contains(id.name.as_str()) || !is_compiled(id.name.as_str()) { continue; } if collect_excluded_ranges(class, consts, &mut excluded) { @@ -92,22 +95,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/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index f6b7c589d..0df9ea8a2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5874,7 +5874,7 @@ fn implicit_defer_candidates<'a>( /// The class names two or more classes of the file declare (in different /// functions or blocks). -fn ambiguous_class_names<'a>( +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(); diff --git a/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs b/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs index 0a95bbe0e..128ddcc24 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,6 +172,46 @@ 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 // ----------------------------------------------------------------- From 57295ac049f06a0342583549b00e47bba9612396 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:30:48 +0800 Subject: [PATCH 21/37] fix(debug-info): take the line of the class keyword, not a decorator setClassDebugInfo's lineNumber came from the class span start. A class with no `export` (and every nested class) has its decorators in that span, so the line was the decorator's: 3 for `@Component(...)\nclass Foo` on lines 3-4. ngtsc takes the line of `clazz.name.pos`, the end of the `class` keyword. Find that keyword after the last decorator, past modifiers and comments, and take its line. Co-Authored-By: Claude Opus 5.5 --- .../src/class_debug_info/metadata.rs | 28 ++++++++++ .../src/class_debug_info/mod.rs | 2 +- .../src/component/decorator.rs | 3 + .../src/component/metadata.rs | 5 ++ .../src/component/transform.rs | 2 +- .../tests/integration_test.rs | 56 +++++++++++++++++++ 6 files changed, 94 insertions(+), 2 deletions(-) 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/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 3722ee90c..8c287e1b5 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -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. diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index e828e4b01..3df963f18 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>, @@ -966,6 +970,7 @@ impl<'a> ComponentMetadata<'a> { Self { class_name, class_span, + class_name_pos: class_span.start, selector: None, template: None, template_span: None, diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 0df9ea8a2..6b877cb8a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6600,7 +6600,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()) diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index dac7f6c48..446ad08de 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -16909,6 +16909,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(); From 888a3cefbcea60dc415a1013f689ae31b714e7a3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:37:53 +0800 Subject: [PATCH 22/37] fix(transform): compile decorated classes in a namespace A decorated class in a `namespace` body was left as is: the class walk skipped module blocks. ngtsc compiles it like a class nested in a function (definitions on the class, constants and child templates at the file top), but only adds statements after a class in a block or the file, so a namespace member gets no class metadata, debug info, NgModule scope or HMR code. It is in the .d.ts, inside its namespace: its declaration is named `NS.App`, so the injection finds that class and not a same-named one elsewhere. As it gets no HMR code, it does not make a top-level class of its name ambiguous for HMR, and its template stays in the HMR fingerprint. A `declare` namespace or module is still left alone. Co-Authored-By: Claude Opus 5.5 --- .../src/component/hmr_fingerprint.rs | 8 +- .../src/component/transform.rs | 92 ++++++---- crates/oxc_angular_compiler/src/dts.rs | 2 +- crates/oxc_angular_compiler/src/dts_inject.rs | 57 ++++--- crates/oxc_angular_compiler/src/util/mod.rs | 69 ++++++-- .../tests/hmr_fingerprint_test.rs | 26 +++ .../tests/nested_class_test.rs | 159 +++++++++++++++++- napi/angular-compiler/index.d.ts | 7 +- napi/angular-compiler/src/lib.rs | 5 +- 9 files changed, 352 insertions(+), 73 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs index 3eb128b3d..ac09dcd5e 100644 --- a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs +++ b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs @@ -78,13 +78,17 @@ pub(super) fn hmr_fingerprint_of_program( let mut components = Vec::new(); let mut excluded = Vec::new(); // Every class the transform compiles, nested ones too. A name two classes - // declare compiles without HMR, so neither is read. + // 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 ambiguous.contains(id.name.as_str()) || !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) { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 6b877cb8a..21e9ee3e1 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3329,6 +3329,9 @@ pub fn transform_angular_file( // The compiled classes nested in a function or block, by span: the // file-level declarations of their compile go after the imports. let mut nested_compiled: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + // 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 @@ -3610,10 +3613,23 @@ pub fn transform_angular_file( 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.nested { nested_compiled.insert(class.span); } + if site.namespace.is_some() { + namespace_compiled.insert(class.span); + } // Pre-flight: catch @Service co-located with another Angular // decorator before the primary-decorator branches dispatch. @@ -3913,8 +3929,14 @@ pub fn transform_angular_file( #[cfg(not(feature = "cross_file_elision"))] let (import_selector, can_defer) = (None, None); + // A `namespace` member has nowhere to put its HMR code. let options = match &hmr_options { - Some(no_hmr) if ambiguous_names.contains(class_name.as_str()) => no_hmr, + Some(no_hmr) + if site.namespace.is_some() + || ambiguous_names.contains(class_name.as_str()) => + { + no_hmr + } _ => options, }; @@ -4260,16 +4282,17 @@ pub fn transform_angular_file( &parser_ret.program.comments, dts_core_namespace.as_str(), ); - // A nested class is not in the `.d.ts`. - if !site.nested { - 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, + ), )); } @@ -4423,13 +4446,13 @@ pub fn transform_angular_file( &parser_ret.program.comments, dts_core_namespace.as_str(), ); - if !site.nested { - result.dts_declarations.push(dts::generate_directive_dts( + 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 + @@ -4550,13 +4573,13 @@ 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); - if !site.nested { - result.dts_declarations.push(dts::generate_pipe_dts( + 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). @@ -4680,13 +4703,13 @@ 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); - if !site.nested { - result.dts_declarations.push(dts::generate_ng_module_dts( + 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). @@ -4790,12 +4813,12 @@ pub fn transform_angular_file( emitter.emit_expression(&definition.prov_definition) ); - if !site.nested { - result.dts_declarations.push(dts::generate_service_dts( + 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( @@ -4876,12 +4899,12 @@ 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); - if !site.nested { - result.dts_declarations.push(dts::generate_injectable_dts( + 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. @@ -5113,7 +5136,7 @@ pub fn transform_angular_file( } // 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))); } } @@ -5542,14 +5565,16 @@ pub fn compile_component_hmr_update( update.fingerprint = Some(fingerprint); // A nested class too: the transform compiles those, with an HMR - // initializer like ngtsc's, unless another class has its name. + // 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; - class.id.as_ref().is_some_and(|id| id.name == class_name).then_some(class) + (declared.namespace.is_none() && class.id.as_ref().is_some_and(|id| id.name == class_name)) + .then_some(class) }) else { return update; }; @@ -5873,13 +5898,18 @@ fn implicit_defer_candidates<'a>( } /// The class names two or more classes of the file declare (in different -/// functions or blocks). +/// 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_map(|declared| declared.class.id.as_ref()) { + 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()); } 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..21b7c0907 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,6 +155,8 @@ 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, @@ -162,6 +165,11 @@ fn find_class_body<'a, 'b>( ns: &'b oxc_ast::ast::TSNamespaceDeclaration<'a>, class_name: &str, ) -> Option<&'b ClassBody<'a>> { + let class_name = match class_name.split_once('.') { + Some((namespace, rest)) if namespace == ns.id.name.as_str() => rest, + Some(_) => return None, + None => class_name, + }; match &ns.body { TSNamespaceDeclarationBody::TSModuleBlock(block) => { find_class_body(&block.body, class_name) @@ -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,26 +206,31 @@ 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 diff --git a/crates/oxc_angular_compiler/src/util/mod.rs b/crates/oxc_angular_compiler/src/util/mod.rs index 4feb7c401..99b0bf5f0 100644 --- a/crates/oxc_angular_compiler/src/util/mod.rs +++ b/crates/oxc_angular_compiler/src/util/mod.rs @@ -43,31 +43,76 @@ pub struct DeclaredClass<'p, 'a> { /// 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 or block rather than at the top level. - /// ngtsc compiles these too, and hoists their constants to the file top. + /// 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 `namespace` and `declare -/// class`es are left out, as are class expressions: TypeScript rejects -/// decorators on those. +/// 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; - struct Nested<'a>(std::vec::Vec<*const Class<'a>>); + #[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 { - self.0.push(class); + 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, _: &oxc_ast::ast::TSModuleBlock<'a>) {} + 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(); @@ -84,19 +129,19 @@ pub fn declared_classes<'p, 'a>( }, _ => None, }; - let mut nested = Nested(std::vec::Vec::new()); + 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 in nested.0 { + 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 }); + classes.push(DeclaredClass { class, top_level: stmt, nested: true, namespace }); } if let Some(class) = class { - classes.push(DeclaredClass { class, top_level: stmt, nested: false }); + classes.push(DeclaredClass { class, top_level: stmt, nested: false, namespace: None }); } } classes diff --git a/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs b/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs index 128ddcc24..484e78585 100644 --- a/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs +++ b/crates/oxc_angular_compiler/tests/hmr_fingerprint_test.rs @@ -216,6 +216,32 @@ function two() {{ @Component({{ template: '' }}) class B {{}} return B; }} // 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/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs index 6dc670afc..41751c1e0 100644 --- a/crates/oxc_angular_compiler/tests/nested_class_test.rs +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -6,8 +6,9 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::{ - CompilationMode, ComponentHmrUpdateInput, ResolvedResources, TransformOptions, TransformResult, - compile_component_hmr_update, extract_component_resource_urls, transform_angular_file, + 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 { @@ -411,3 +412,157 @@ export class D { v = input(1); } 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 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/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, From fd91a7435dff97ee4a364ae5325641a311fae13f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:31:57 +0800 Subject: [PATCH 23/37] fix(linker): chain consecutive query creation calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker emitted one statement per query creation call (ɵɵviewQuery(_c0, 5); ɵɵviewQuery(_c1, 5);) and one ɵɵqueryAdvance() per signal query. Angular's linker compiles the queries with createViewQueriesFunction / createContentQueriesFunction, which chain the calls of one instruction (ɵɵviewQuery(_c0, 5)(_c1, 5)) and collapse consecutive advances into one (ɵɵqueryAdvance(3)). Do the same, with the file transform's version gate for the chaining: runtimes before v21.0.4 return nothing from the query instructions. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 77 +++++++++++-------- .../oxc_angular_compiler/tests/linker_test.rs | 42 ++++++++-- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index cc1e6fdd5..77fa1be05 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1424,7 +1424,7 @@ fn link_directive( mut declarations_js, next_pool_index, mut shared_pool, - } = link_queries(&allocator, meta, source, ns, type_name); + } = link_queries(&allocator, meta, source, ns, type_name, options.angular_version); if let Some(cq_fn) = content_queries { parts.push(format!("contentQueries: {cq_fn}")); } @@ -1592,6 +1592,7 @@ fn build_queries( type_name: &str, is_content_query: bool, pool: &mut ConstantPool<'_>, + angular_version: Option, ) -> Option { if queries.elements.is_empty() { return None; @@ -1600,6 +1601,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 { @@ -1657,38 +1673,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})")); - } - } else if is_signal { - let mut args = format!("ctx.{prop_name}, {predicate}, {flags}"); - if let Some(read_expr) = read { - args = format!("{args}, {read_expr}"); + let (instruction, mut args) = match (is_content_query, is_signal) { + (true, true) => { + ("contentQuerySignal", format!("dirIndex, ctx.{prop_name}, {predicate}, {flags}")) } - 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 { @@ -1702,6 +1712,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"); @@ -1800,12 +1812,15 @@ fn link_queries<'a>( source: &str, ns: &str, type_name: &str, + angular_version: Option, ) -> LinkedQueries<'a> { let mut pool = ConstantPool::new(allocator); - let content_queries = get_array_property(meta, "queries") - .and_then(|queries| build_queries(queries, source, ns, type_name, true, &mut pool)); - let view_query = get_array_property(meta, "viewQueries") - .and_then(|queries| build_queries(queries, source, ns, type_name, false, &mut pool)); + 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) + }); LinkedQueries { content_queries, @@ -1945,7 +1960,7 @@ fn link_component( declarations_js: query_declarations_js, next_pool_index: query_next_pool_index, mut shared_pool, - } = link_queries(&template_allocator, meta, source, ns, type_name); + } = link_queries(&template_allocator, meta, source, ns, type_name, options.angular_version); let host_obj = get_object_property(meta, "host"); let host_output = host_obj.and_then(|host_obj| { // Extract host properties and listeners as HostMetadataInput and compile diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index ba43819c0..e4ed202a9 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -745,9 +745,7 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.2.2", t 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);", - "i0.ɵɵviewQuery(TemplateRef,5);", - "i0.ɵɵviewQuery(_c1,5);", + "i0.ɵɵviewQuery(_c2,5)(TemplateRef,5)(_c1,5);", "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c3));", "ngContentSelectors:_c2,", ] { @@ -770,8 +768,7 @@ D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.2", t // 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);", - "i0.ɵɵcontentQuery(dirIndex,_c1,4);", + "i0.ɵɵcontentQuery(dirIndex,_c0,5)(dirIndex,_c1,4);", "i0.ɵɵviewQuery(_c0,5);", "i0.ɵɵattribute(\"a\",i0.ɵɵpureFunction0(1,_c2));", ] { @@ -793,9 +790,7 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", t 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);", - "i0.ɵɵviewQuery(_c1,5);", - "i0.ɵɵviewQuery(_c2,5);", + "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); @@ -817,6 +812,37 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", n assert!(result.code.contains("[\"y\u{85}\"]"), "{}", 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"; From c04b1a07270ec3547db98c521f7d33b6f132e046 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:36:10 +0800 Subject: [PATCH 24/37] fix(linker): compile standalone templates without directive deps in DomOnly mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker always compiled templates in Full mode, so a standalone component with no directive dependencies got ɵɵelementStart and ɵɵproperty where Angular 22's linker emits ɵɵdomElementStart and ɵɵdomProperty. The version was not the cause: an explicit 22.2.2 gave the same output. Angular's linker sets hasDirectiveDependencies to !isStandalone, a directive, component or NgModule dependency, or a @defer block with a dependency function, and compileComponentFromMetadata picks DomOnly without them. Do the same, for Angular 20.1 and later (and an unset version), like the file transform. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 17 +++++- crates/oxc_angular_compiler/src/linker/mod.rs | 21 ++++++- .../oxc_angular_compiler/tests/linker_test.rs | 59 +++++++++++++++++-- napi/angular-compiler/test/linker.test.ts | 12 ++-- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 21e9ee3e1..c23ae0b41 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -7963,6 +7963,10 @@ 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`]). +/// +/// `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, @@ -7972,6 +7976,7 @@ 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>, ) -> Result> { @@ -8038,11 +8043,19 @@ 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, + mode, relative_context_file_path: None, i18n_use_external_ids: true, defer_block_deps_emit_mode: DeferBlockDepsEmitMode::PerBlock, diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 77fa1be05..a246e9f01 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1900,6 +1900,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 @@ -2008,6 +2026,7 @@ fn link_component( ns, options.angular_version, &defer_block_deps, + has_directive_dependencies(meta), attrs_pool.next_name_index(), attrs_pool.take_shared(), ) @@ -2901,7 +2920,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/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index e4ed202a9..d29f0549b 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(); @@ -812,6 +812,57 @@ C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", n 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}" + ); + } + } +} + /// `ɵɵ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"; 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)') }) }) From b1bfe392f182814b91020eb23efb19ce8c10f1b8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:37:32 +0800 Subject: [PATCH 25/37] fix(linker): name i18n closure messages after the linked file The linker compiled templates without a context file path and with external ids, so its Closure message names were MSG_APP_0 (or MSG_EXTERNAL_$$APP_1 for a custom id). Angular's linker sets relativeContextFilePath to the linked file's path and i18nUseExternalIds to false, which gives MSG__X_LIB_A_COMPONENT_MJS_0 for /x/lib/a.component.mjs. Pass the same. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 6 +++-- .../oxc_angular_compiler/tests/linker_test.rs | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c23ae0b41..13c957945 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -8056,8 +8056,10 @@ pub fn compile_template_for_linker<'a>( // Stage 3-5: Ingest and compile let ingest_options = IngestOptions { mode, - relative_context_file_path: None, - i18n_use_external_ids: true, + // Angular's linker names Closure messages after the file it links + // (`MSG__X_MAIN_JS_0` for `/x/main.js`), without external 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, diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index d29f0549b..18ae53491 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -863,6 +863,28 @@ fn test_link_dom_only_template() { } } +/// 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"; From e2bfd714a74ac8e8f83f64f144ddfe5cb3536743 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:55:21 +0800 Subject: [PATCH 26/37] fix(linker): share one constant pool per scope like Angular's linker The linker compiled each partial declaration with its own constant pool and kept the constants in an IIFE per declaration, so the _cN, i18n_N and MSG_ numbers restarted for each declaration of a file and equal constants (query predicates, pure functions, track functions, long styles) were declared again. Angular's FileLinker gives the declarations whose ngImport is bound in the same module, function declaration or function expression one EmitScope with one ConstantPool, whose statements the babel plugin puts once at the top of that scope: before the first statement that is not an import, or at the start of the function body. Any other binding (an arrow function, a method, a global) gets a LocalEmitScope, which keeps the constants in an IIFE. The main template function goes inline in the definition. A long style is inline on its first use and shared from its second; a first use in an earlier declaration stays inline, since that declaration has been emitted already. Do the same: find the binding of ngImport with oxc_semantic, carry the pool of its scope from one declaration to the next, insert the scope's statements once, inline the template function, pool the styles after the template, and name the host binding function with the pool, as Angular's addNamesToView does (A_HostBindings1 for a second class A). Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 64 +-- crates/oxc_angular_compiler/src/linker/mod.rs | 449 +++++++++++++----- .../src/pipeline/phases/naming.rs | 9 +- .../oxc_angular_compiler/tests/linker_test.rs | 175 ++++++- 4 files changed, 540 insertions(+), 157 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 13c957945..90df7ad38 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -7857,6 +7857,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(); @@ -7934,12 +7938,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, @@ -7962,7 +7970,8 @@ 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, @@ -7978,7 +7987,7 @@ pub fn compile_template_for_linker<'a>( 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; @@ -8082,9 +8091,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()); @@ -8154,40 +8166,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/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index a246e9f01..fd132d988 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -38,17 +38,24 @@ 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, 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::scope::{ScopeFlags, ScopeId}; +use rustc_hash::FxHashMap; use crate::AngularVersion; use crate::directive::split_query_predicate; use crate::optimizer::Edit; -use crate::output::ast::{LiteralArrayExpr, LiteralExpr, LiteralValue, OutputExpression}; +use crate::output::ast::{ + DeclareVarStmt, LiteralArrayExpr, LiteralExpr, LiteralValue, OutputExpression, OutputStatement, + StmtModifier, +}; use crate::output::emitter::JsEmitter; use crate::output::emitter::escape_string; use crate::output::emitter::is_left_hand_side_source; @@ -140,7 +147,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 }; @@ -159,25 +166,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, @@ -187,17 +249,153 @@ struct DeclarationCollector<'s> { statement_start: Option<(Span, bool)>, /// The expression of the decorator being visited decorator: Option, -} - -impl DeclarationCollector<'_> { - fn visit_statement_expression<'a>(&mut self, expr: &Expression<'a>, is_statement: bool) { + 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<'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) + } + + /// 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)) 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)) + } } -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 { @@ -229,8 +427,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 @@ -839,14 +1036,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)?; @@ -860,13 +1060,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. @@ -1403,13 +1606,15 @@ 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") { @@ -1417,14 +1622,8 @@ fn link_directive( } // Content queries, then view queries — pooled before the host bindings - let allocator = Allocator::default(); - let LinkedQueries { - content_queries, - view_query, - mut declarations_js, - next_pool_index, - mut shared_pool, - } = link_queries(&allocator, meta, source, ns, type_name, options.angular_version); + 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}")); } @@ -1468,15 +1667,16 @@ 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, + allocator, &host_input, type_name, selector, - next_pool_index, - &mut shared_pool, + 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)); } @@ -1487,16 +1687,7 @@ fn link_directive( let define_call = format!("/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineDirective({{ {} }})", parts.join(", ")); - if declarations_js.is_empty() { - Some(define_call) - } else { - // The IIFE only declares the query predicates and 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{declarations_js}\nreturn {define_call};\n}})()")) - } + (define_call, declarations_js) } /// Extract an array expression property value from an object expression. @@ -1790,17 +1981,13 @@ fn pool_string_array<'a>( } /// The `contentQueries` and `viewQuery` functions of a directive or component. -struct LinkedQueries<'a> { +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, - /// The next constant index after the predicates. - next_pool_index: u32, - /// The pooled predicates, for the host bindings and the template to reuse. - shared_pool: SharedPool<'a>, } /// Build the query functions of a declaration, pooling their string @@ -1808,13 +1995,14 @@ struct LinkedQueries<'a> { /// 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<'a> { - let mut pool = ConstantPool::new(allocator); +) -> 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) }); @@ -1822,13 +2010,9 @@ fn link_queries<'a>( build_queries(queries, source, ns, type_name, false, &mut pool, angular_version) }); - LinkedQueries { - content_queries, - view_query, - declarations_js: declare_pooled_constants_js(&mut pool, ns), - next_pool_index: pool.next_name_index(), - shared_pool: pool.take_shared(), - } + 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. @@ -1931,24 +2115,23 @@ fn has_directive_dependencies(meta: &ObjectExpression<'_>) -> bool { /// 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 @@ -1972,40 +2155,32 @@ fn link_component( // 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, - next_pool_index: query_next_pool_index, - mut shared_pool, - } = link_queries(&template_allocator, meta, source, ns, type_name, options.angular_version); + 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 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"), - query_next_pool_index, - &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 = ConstantPool::with_starting_index( - &template_allocator, - host_output - .as_ref() - .map_or(query_next_pool_index, |host_output| host_output.next_pool_index), - ); - attrs_pool.inherit_shared(shared_pool); + 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()) @@ -2016,9 +2191,10 @@ fn link_component( 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, @@ -2027,10 +2203,11 @@ fn link_component( options.angular_version, &defer_block_deps, has_directive_dependencies(meta), - attrs_pool.next_name_index(), - attrs_pool.take_shared(), + 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(); @@ -2128,8 +2305,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` @@ -2240,30 +2417,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(", "))); } } @@ -2314,19 +2541,17 @@ fn link_component( let define_component = format!("/*@__PURE__*/ {ns}.\u{0275}\u{0275}defineComponent({{ {} }})", parts.join(", ")); - // Wrap in IIFE with query, host binding, attrs and template declarations, - // in the order they were pooled + // 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)] 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/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 18ae53491..1c86517ed 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -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); @@ -863,6 +864,172 @@ fn test_link_dom_only_template() { } } +/// 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(); + } +} + /// 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] From 87d6e52ab8ec3e136102fc78260ab38b350ecefa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 17:22:18 +0800 Subject: [PATCH 27/37] fix(linker): keep defer deps that use inner bindings local Since the linker shares one constant pool per scope, a @defer dependency function went to the top of the scope binding ngImport. When it used a binding of a scope inside it, such as a class declared in the function that declares the component, it threw a ReferenceError there. Angular 22.2.3's linker moves it to the top all the same, so its output breaks too. Keep such a declaration in a local IIFE with its own pool, as Angular's LocalEmitScope does and as the linker did before. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 40 +++++++++++++++++-- .../oxc_angular_compiler/tests/linker_test.rs | 35 ++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index fd132d988..36e2712d8 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -38,14 +38,15 @@ use std::borrow::Cow; use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrayExpressionElement, BindingPattern, CallExpression, Decorator, - ExportDefaultDeclaration, Expression, ExpressionStatement, Function, MethodDefinition, - ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, PropertyKey, PropertyKind, - 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; @@ -280,6 +281,25 @@ impl<'a> DeclarationCollector<'_, 'a> { 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)?; @@ -287,7 +307,10 @@ impl<'a> DeclarationCollector<'_, 'a> { Expression::StaticMemberExpression(member) => Some(&member.object), _ => None, }); - let Some(scope) = ng_import.and_then(|ng_import| self.constant_scope(ng_import)) else { + 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( @@ -334,6 +357,15 @@ impl<'a> DeclarationCollector<'_, 'a> { } } +/// 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<'_, 'a> { fn visit_program(&mut self, program: &Program<'a>) { // Babel parses a file as a module unless told otherwise, so Angular's diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 1c86517ed..7a5456551 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1030,6 +1030,41 @@ export function f3() { class A {} A.ɵdir = i0.ɵɵngDeclareDirective({ minVersi } } +/// 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] From bb10d3598bd11aa468dff10968adccdc0628e3a7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:41:19 +0800 Subject: [PATCH 28/37] fix(defer): give same-file @defer dependencies their own resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `resolveImportedValues`, a `@defer` block that uses a component declared in the same file got no dependency function, and `ɵɵdefer` had no resolver argument. ngtsc's full compilation emits one per block (`const A_Defer_1_DepsFn = () => [Dep]`) and drops a class used only inside `@defer` from `dependencies`. The selector of a class of the file needs no other file, so it is now read without cross-file analysis too. Co-Authored-By: Claude Opus 5.5 --- .../src/component/cross_file_elision.rs | 67 +------------------ .../src/component/defer_resolver.rs | 67 +++++++++++++++++++ .../src/component/transform.rs | 29 ++++++-- .../tests/integration_test.rs | 33 +++++++++ 4 files changed, 128 insertions(+), 68 deletions(-) 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..e8ba8f832 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,6 +29,7 @@ use oxc_resolver::{ use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; +use super::defer_resolver::decorated_class_selector; use crate::directive::{ImportValueResolver, StringConsts}; /// Result of analyzing a file's exports @@ -213,68 +214,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. /// diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index 9195b5835..71baf08f6 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -39,6 +39,10 @@ //! ([`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::BindingType; use crate::ast::r3::{ @@ -49,6 +53,7 @@ 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, @@ -210,6 +215,68 @@ pub fn build_defer_resolver_expression<'a>( Some(compile_component_metadata_async_resolver(allocator, &deps)) } +/// 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(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) +} + +/// The selector of `class` when it is a standalone `@Component` or +/// `@Directive` whose `selector` and `standalone` are literals. +pub(super) 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()) +} + /// 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. diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 90df7ad38..cfcbca731 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3566,7 +3566,7 @@ pub fn transform_angular_file( 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( + let selector = super::defer_resolver::local_standalone_class_selector( program, string_consts, local, @@ -3587,6 +3587,21 @@ pub fn transform_angular_file( Some((selector, true)) } }); + // 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 = |local: &str| -> Option<(String, bool)> { + if import_map.contains_key(&Ident::from(local)) { + return None; + } + let selector = super::defer_resolver::local_standalone_class_selector( + &parser_ret.program, + &string_consts, + local, + options.implicit_standalone(), + )?; + Some((selector, false)) + }; // 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. @@ -3923,11 +3938,17 @@ 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 { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 446ad08de..5c28817af 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] From d53f479739ba8bd2f22f2eab4d00cb5b9d0c6909 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:49:32 +0800 Subject: [PATCH 29/37] fix(defer): read @defer dependencies through a const imports array With `imports: IMPORTS` and `const IMPORTS = [Dep, Dep2]` in the same file, no `@defer` block got its own dependency function. ngtsc resolves the `const` and lists the classes each block uses in a per-block `DepsFn`, in full and partial mode. It reads each class through the identifier in the `const`'s array, so components naming the same array share one function. Like its partial evaluator, the array is read through `let`, `as const` and a variable naming another one. The classes stay eager, since only entries of an `imports` array literal can be lazy-loaded. Co-Authored-By: Claude Opus 5.5 --- .../src/component/decorator.rs | 64 ++++++++++++- .../src/component/defer_resolver.rs | 22 +++-- .../src/component/metadata.rs | 20 +++++ .../oxc_angular_compiler/src/component/mod.rs | 4 +- .../src/component/transform.rs | 29 +++--- .../tests/defer_implicit_imports_test.rs | 89 +++++++++++++++++++ 6 files changed, 197 insertions(+), 31 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 8c287e1b5..2613094db 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; @@ -185,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); @@ -517,6 +518,63 @@ fn extract_import_entry_classes<'a>( result } +/// The classes an `imports` value names: the entries of an array literal +/// that [`extract_import_entry_classes`] reads, or of the array a same-file +/// `const` holds when `imports` names one (`imports: IMPORTS`). The spans are +/// those of the identifiers in the `const`'s array, which every component +/// naming it shares. +fn extract_import_refs<'a>( + allocator: &'a Allocator, + expr: &'a Expression<'a>, + consts: &StringConsts<'a>, +) -> Vec<'a, ImportRef<'a>> { + let expr = match expr { + Expression::Identifier(id) => match top_level_const_init(consts, &id.name) { + Some(init @ Expression::ArrayExpression(_)) => init, + _ => return Vec::new_in(&allocator), + }, + _ => expr, + }; + let mut refs = Vec::new_in(&allocator); + for (name, span) in extract_import_entry_classes(allocator, expr, consts).into_iter().flatten() + { + refs.push(ImportRef { name, span }); + } + refs +} + +/// 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 71baf08f6..d1205dce3 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -63,7 +63,7 @@ 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. @@ -358,15 +358,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() { @@ -381,13 +381,11 @@ 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)); + let imports_identifier = + import_refs.iter().find(|import| import.name == *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) }, + Some(import) => OutputExpression::WrappedNode(oxc_allocator::Box::new_in( + WrappedNodeExpr { node_id: *name, source_span: Some(import.span) }, &allocator, )), None => OutputExpression::ReadVar(oxc_allocator::Box::new_in( diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 3df963f18..fe72df5b3 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -554,6 +554,13 @@ 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, read through a same-file `const` array + /// (`imports: IMPORTS`) the way ngtsc's partial evaluator resolves it. + /// Unlike [`Self::import_entry_classes`], these may come from outside the + /// array literal, so `@defer` lists them in its blocks' dependency + /// functions but never lazy-loads them. + 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"), @@ -711,6 +718,18 @@ 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 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, +} + /// Metadata for a host directive. /// /// Corresponds to TypeScript's `HostDirectiveDef` in the compiler. @@ -984,6 +1003,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 cfcbca731..e8d1f4dfb 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5876,29 +5876,30 @@ struct DeferCandidates<'a> { } /// The [`DeferCandidates`] of a component, or `None` when its template has -/// no `@defer` block or its `imports` isn't an array literal. An entry that -/// doesn't name a class (a spread, a nested array, `ns.Cmp`) is never a -/// candidate, as ngc's `registerDeferrableCandidate` skips anything but an -/// identifier, and the classes it brings in stay eager. +/// 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() - || !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 + .import_refs .iter() - .flatten() - .map(|(name, _)| *name) + .map(|import| import.name) .filter_map(|name| { let (selector, is_imported) = import_selector(name.as_str())?; if is_imported { @@ -6203,7 +6204,7 @@ fn compile_component_full<'a>( allocator, &dependencies, &metadata.deferred_imports, - &metadata.import_entry_classes, + &metadata.import_refs, import_map, ) }) @@ -6397,7 +6398,7 @@ fn compile_component_full<'a>( allocator, &dependencies, &metadata.deferred_imports, - &metadata.import_entry_classes, + &metadata.import_refs, import_map, ); (span, deps_fn) 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 76add8d87..6e49e47b8 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -1682,6 +1682,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 --- From ca1952500709ceabd8983f6c8cc5605d9c70844b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:59:26 +0800 Subject: [PATCH 30/37] fix(defer): list classes of non-identifier imports entries in deps fns A `@defer` block's dependency function left out the classes that a nested array (`[OtherB]`), a spread of a same-file `const` array or a namespace member (`ns.ChildB`) brings in. ngtsc flattens and resolves `imports` into its scope, so the block's `DepsFn` lists them, eagerly (`[ChildA, OtherB]`), and lists a class two entries name once: for `[ChildA, ns.ChildA, ns.ChildB]` it gives `[ChildA, i1.ChildB]`. The namespace member is emitted as written (`ns.ChildB`) rather than through a new import. Co-Authored-By: Claude Opus 5.5 --- .../src/component/cross_file_elision.rs | 22 ++-- .../src/component/decorator.rs | 68 +++++++--- .../src/component/defer_resolver.rs | 40 ++++-- .../src/component/metadata.rs | 16 ++- .../src/component/transform.rs | 117 ++++++++++++------ .../tests/defer_implicit_imports_test.rs | 53 ++++++++ 6 files changed, 236 insertions(+), 80 deletions(-) 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 e8ba8f832..9f25d9b23 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -296,7 +296,9 @@ impl CrossFileAnalyzer { /// 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). + /// whether a template matches 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 @@ -309,7 +311,7 @@ impl CrossFileAnalyzer { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option { + ) -> Option<(String, (String, String))> { self.inner.standalone_directive_selector( import_source, import_name, @@ -478,7 +480,7 @@ impl Inner { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option { + ) -> Option<(String, (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 { @@ -495,12 +497,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 == "*" { @@ -2028,7 +2028,9 @@ 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")); diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 2613094db..55aacef6d 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -518,29 +518,67 @@ fn extract_import_entry_classes<'a>( result } -/// The classes an `imports` value names: the entries of an array literal -/// that [`extract_import_entry_classes`] reads, or of the array a same-file -/// `const` holds when `imports` names one (`imports: IMPORTS`). The spans are -/// those of the identifiers in the `const`'s array, which every component -/// naming it shares. +/// 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 expr = match expr { + 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; + } + match try_unwrap_forward_ref(expr, Some(consts)).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(_)) => init, - _ => return Vec::new_in(&allocator), + 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 }) + } }, - _ => expr, - }; - let mut refs = Vec::new_in(&allocator); - for (name, span) in extract_import_entry_classes(allocator, expr, consts).into_iter().flatten() - { - refs.push(ImportRef { name, span }); + 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, + }); + } + } + _ => {} } - refs } /// The initializer of the top-level variable (`const`, `let` or `var`, diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index d1205dce3..b6e30cb97 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -56,8 +56,8 @@ use crate::class_metadata::{ 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; @@ -381,17 +381,33 @@ pub(super) fn compile_defer_block_resolver<'a>( } else { None }; - let imports_identifier = - import_refs.iter().find(|import| import.name == *name && import_map.contains_key(name)); - entries.push(deferrable.unwrap_or_else(|| match imports_identifier { - Some(import) => OutputExpression::WrappedNode(oxc_allocator::Box::new_in( - WrappedNodeExpr { node_id: *name, source_span: Some(import.span) }, + 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, - )), - None => OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: *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( diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index fe72df5b3..b0985f66e 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -554,11 +554,13 @@ 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, read through a same-file `const` array - /// (`imports: IMPORTS`) the way ngtsc's partial evaluator resolves it. - /// Unlike [`Self::import_entry_classes`], these may come from outside the - /// array literal, so `@defer` lists them in its blocks' dependency - /// functions but never lazy-loads them. + /// 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. 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. @@ -722,6 +724,10 @@ pub struct ComponentMetadata<'a> { /// [`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 diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index e8d1f4dfb..22a749bac 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}; @@ -3564,43 +3564,38 @@ 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::defer_resolver::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); }; - if !info.is_named_import || info.is_type_only { + // `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_type_only { return None; } - let export_name = info.imported_name.as_deref().unwrap_or(local); - let selector = analyzer.standalone_directive_selector( + let (selector, declaration) = analyzer.standalone_directive_selector( info.source_module.as_str(), export_name, std::path::Path::new(path), implicit_standalone, )?; - Some((selector, true)) + Some(ImportedClass { selector, 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 = |local: &str| -> Option<(String, bool)> { - if import_map.contains_key(&Ident::from(local)) { + let local_selector = |import: &ImportRef<'_>| -> Option { + if import_map.contains_key(&import.namespace.unwrap_or(import.name)) { return None; } - let selector = super::defer_resolver::local_standalone_class_selector( - &parser_ret.program, - &string_consts, - local, - options.implicit_standalone(), - )?; - Some((selector, false)) + 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 @@ -5852,10 +5847,44 @@ 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 { + selector: String, + /// 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 [`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 selector = super::defer_resolver::local_standalone_class_selector( + program, + consts, + &import.name, + implicit_standalone, + )?; + Some(ImportedClass { + selector, + 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 @@ -5896,18 +5925,30 @@ fn implicit_defer_candidates<'a>( return None; } let mut imported = std::vec::Vec::new(); - let candidates: std::vec::Vec<_> = metadata - .import_refs - .iter() - .map(|import| import.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<(Ident<'a>, _)> = 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); + } + match declarations.iter().position(|declaration| *declaration == class.declaration) { + Some(index) => { + if class.is_imported && !imported.contains(&candidates[index].0) { + candidates[index].0 = import.key; + } } - Some((name, crate::pipeline::selector::CssSelector::parse(&selector))) - }) - .collect(); + None => { + declarations.push(class.declaration); + candidates.push(( + import.key, + crate::pipeline::selector::CssSelector::parse(&class.selector), + )); + } + } + } let block_only = super::defer_resolver::implicitly_deferred_imports(nodes, &candidates); let marked = metadata .import_entry_classes 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 6e49e47b8..fa22458df 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -429,6 +429,59 @@ export class Parent {} 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 forward_ref_not_from_angular_core_stays_eager() { let dir = TempDir::new().unwrap(); From e9eaa17d4a2757bf5d8166410d6c18c37ea21618 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 17:09:05 +0800 Subject: [PATCH 31/37] fix(partial): list classes of every imports entry in dependencies The partial declaration's `dependencies` was built only from the plain identifiers of an `imports` array literal, so a nested array (`[OtherB]`), a namespace member (`ns.ChildB`), a spread of a same-file `const` array, a `forwardRef(() => X)` entry and a non-literal `imports: IMPORTS` were dropped, with or without `@defer`. ngtsc flattens and resolves `imports` before it lists `dependencies`. They are now listed once per class: `Cmp` and `ns.Cmp` are one class when they read one export of one module, or when cross-file analysis finds one declaration, and a class `@defer` lazy-loads is left out however else `imports` names it. Co-Authored-By: Claude Opus 5.5 --- .../src/component/decorator.rs | 15 +++- .../src/component/metadata.rs | 16 +++- .../src/component/transform.rs | 57 +++++++++++++- .../src/partial/component.rs | 63 ++++++++++----- .../tests/defer_implicit_imports_test.rs | 72 +++++++++++++++++ .../tests/partial_component_test.rs | 78 +++++++++++++++++++ 6 files changed, 276 insertions(+), 25 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 55aacef6d..aa2b69c0a 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -545,7 +545,9 @@ fn collect_import_refs<'a>( if depth > 16 { return; } - match try_unwrap_forward_ref(expr, Some(consts)).unwrap_or(expr) { + 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 { @@ -562,9 +564,13 @@ fn collect_import_refs<'a>( 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 }) - } + _ => 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 { @@ -574,6 +580,7 @@ fn collect_import_refs<'a>( namespace: Some(namespace.name), name: member.property.name, span: member.property.span, + is_forward_reference, }); } } diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index b0985f66e..b9c5acc8f 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -557,7 +557,7 @@ pub struct ComponentMetadata<'a> { /// 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. Unlike [`Self::import_entry_classes`], these may come + /// `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. @@ -734,6 +734,8 @@ pub struct ImportRef<'a> { /// 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. @@ -883,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> { @@ -904,6 +909,7 @@ impl<'a> TemplateDependency<'a> { is_component, pipe_name: None, is_forward_reference: false, + type_namespace: None, } } @@ -924,6 +930,7 @@ impl<'a> TemplateDependency<'a> { is_component: false, pipe_name: Some(pipe_name), is_forward_reference: false, + type_namespace: None, } } @@ -933,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); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 22a749bac..df26c6387 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5863,6 +5863,26 @@ struct ImportedClass { 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>, @@ -6119,6 +6139,7 @@ fn resolve_implicit_defer<'a>( 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(|_| { @@ -6235,8 +6256,39 @@ fn compile_component_full<'a>( // blocks are resolved as in a full compilation, and the declaration // carries their dependency functions (`deferBlockDependencies`). if matches!(options.compilation_mode, crate::CompilationMode::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() @@ -6245,7 +6297,7 @@ fn compile_component_full<'a>( allocator, &dependencies, &metadata.deferred_imports, - &metadata.import_refs, + &import_refs, import_map, ) }) @@ -6350,6 +6402,7 @@ fn compile_component_full<'a>( // `imports` entries matched only inside `@defer` are lazy-loaded, as in // 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); @@ -6439,7 +6492,7 @@ fn compile_component_full<'a>( allocator, &dependencies, &metadata.deferred_imports, - &metadata.import_refs, + &import_refs, import_map, ); (span, deps_fn) diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 6da68a030..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::{ @@ -268,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 }; @@ -713,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 } @@ -769,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/tests/defer_implicit_imports_test.rs b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs index fa22458df..0eb4d94ee 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -482,6 +482,78 @@ export class Parent {} ); } +#[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(); 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] From 5de5f39fd23444ec8ac6abfda3bac2777ffac386 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 17:20:41 +0800 Subject: [PATCH 32/37] fix(defer): lazy-load a pipe used only inside @defer A standalone pipe in `imports` used only inside `@defer` stayed eager, in full and partial mode: `@defer` matched the template against selectors only. ngtsc's binder records the pipes each block uses, so the pipe is listed in the block's `DepsFn` as `import("./p").then(m => m.P)`, its static import is removed and the class metadata is set asynchronously. Pipes are now matched by their name, listed after the directives, and a `when` condition counts toward the enclosing template. Co-Authored-By: Claude Opus 5.5 --- .../src/component/cross_file_elision.rs | 40 +-- .../src/component/defer_resolver.rs | 242 ++++++++++++++++-- .../src/component/transform.rs | 40 +-- .../tests/defer_implicit_imports_test.rs | 51 +++- 4 files changed, 301 insertions(+), 72 deletions(-) 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 9f25d9b23..9e8d64d98 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -29,9 +29,13 @@ use oxc_resolver::{ use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; -use super::defer_resolver::decorated_class_selector; +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 { @@ -88,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). @@ -171,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 @@ -293,10 +297,11 @@ 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). It comes + /// 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. /// @@ -311,7 +316,7 @@ impl CrossFileAnalyzer { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option<(String, (String, String))> { + ) -> Option<(ClassUse, (String, String))> { self.inner.standalone_directive_selector( import_source, import_name, @@ -480,7 +485,7 @@ impl Inner { import_name: &str, from_file: &Path, implicit_standalone: bool, - ) -> Option<(String, (String, String))> { + ) -> 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 { @@ -522,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) { @@ -2033,15 +2038,16 @@ export { doSomething } from './utils'; .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/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index b6e30cb97..03c619efa 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -44,10 +44,13 @@ use oxc_ast::ast::{ 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, @@ -215,16 +218,17 @@ pub fn build_defer_resolver_expression<'a>( Some(compile_component_metadata_async_resolver(allocator, &deps)) } -/// 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. +/// 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 { +) -> 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), @@ -243,16 +247,30 @@ pub(super) fn local_standalone_class_selector<'a>( 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. +/// 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 = ["Component", "Directive"].iter().find_map(|name| { - crate::directive::find_angular_class_decorator(&class.decorators, name, consts) - })?; +) -> 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()) @@ -266,7 +284,7 @@ pub(super) fn decorated_class_selector<'a>( 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" { + 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" { @@ -274,15 +292,37 @@ pub(super) fn decorated_class_selector<'a>( standalone = value.value; } } - selector.filter(|selector| standalone && !selector.trim().is_empty()) + 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); @@ -306,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>)>, } @@ -428,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, @@ -436,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, @@ -447,15 +487,98 @@ 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(_) => {} } } @@ -478,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/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index df26c6387..c850fbd3c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3579,13 +3579,13 @@ pub fn transform_angular_file( if info.is_type_only { return None; } - let (selector, declaration) = 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(ImportedClass { selector, is_imported, declaration }) + Some(ImportedClass { class_use, is_imported, declaration }) } }); // Without cross-file analysis, only the classes declared in this file @@ -5854,7 +5854,7 @@ type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; /// What `@defer` reads of the standalone component or directive an `imports` /// entry refers to. struct ImportedClass { - selector: String, + 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, @@ -5893,14 +5893,14 @@ fn local_class<'p>( if import.namespace.is_some() { return None; } - let selector = super::defer_resolver::local_standalone_class_selector( + let class_use = super::defer_resolver::local_standalone_class_selector( program, consts, &import.name, implicit_standalone, )?; Some(ImportedClass { - selector, + class_use, is_imported: false, declaration: (String::new(), import.name.to_string()), }) @@ -5915,7 +5915,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 @@ -5947,28 +5947,30 @@ fn implicit_defer_candidates<'a>( let mut imported = std::vec::Vec::new(); // 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<(Ident<'a>, _)> = std::vec::Vec::new(); + 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); } - match declarations.iter().position(|declaration| *declaration == class.declaration) { - Some(index) => { - if class.is_imported && !imported.contains(&candidates[index].0) { - candidates[index].0 = import.key; - } - } - None => { - declarations.push(class.declaration); - candidates.push(( - import.key, - crate::pipeline::selector::CssSelector::parse(&class.selector), - )); + 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; } + } 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 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 0eb4d94ee..c772828cd 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -690,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] From cf64555a0a3d8109ff7d08d55a1b566aa86ba272 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:38:18 +0800 Subject: [PATCH 33/37] fix(i18n): name closure messages after their computed message id Template i18n messages only had a custom @@id, so with i18nUseExternalIds a message without one was named MSG_N. ngtsc gives every message an id in its first I18nMetaVisitor pass (_setMessageId: the custom id, else decimalDigest of the message before whitespace removal; a sole ICU takes its element's id) and names it MSG_EXTERNAL_$$N, ICU sub-messages and i18n-* attributes included. The $localize meta block keeps only custom ids, as in ngtsc. The linker now compiles with i18nUseExternalIds false, as Angular's linker does, so its names keep the MSG_N form. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 10 +-- crates/oxc_angular_compiler/src/i18n/mod.rs | 4 +- .../src/transform/html_to_r3.rs | 62 ++++++++++++------- .../tests/integration_test.rs | 27 ++++++++ .../oxc_angular_compiler/tests/linker_test.rs | 17 +++++ .../test/hmr-component-update.test.ts | 10 +-- 6 files changed, 97 insertions(+), 33 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c850fbd3c..39a53f71e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -7041,10 +7041,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 &_ }; @@ -7054,7 +7055,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() { @@ -8184,7 +8186,7 @@ pub fn compile_template_for_linker<'a>( let ingest_options = IngestOptions { mode, // Angular's linker names Closure messages after the file it links - // (`MSG__X_MAIN_JS_0` for `/x/main.js`), without external ids. + // (`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, 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/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/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 5c28817af..87fda0f68 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5897,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(); diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 7a5456551..7bbd0b0b7 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1168,3 +1168,20 @@ D.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.25", 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/napi/angular-compiler/test/hmr-component-update.test.ts b/napi/angular-compiler/test/hmr-component-update.test.ts index 79f31a20c..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)) From 6ecf7c13dc3fe3a4ded36ee20264238c825358dd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:44:44 +0800 Subject: [PATCH 34/37] fix(reify): pool a template's track function before its arrow functions All arrow functions of a view were hoisted before any create operation was reified, so an arrowFn of a binding was pooled before the _forTrack of a @for in the same view. ngtsc's reify hoists each create operation's expressions just before reifying it (pooling _forTrack with its repeaterCreate), and the update operations' arrow functions after all create operations, which declares _forTrack0 before arrowFn0. Co-Authored-By: Claude Opus 5.5 --- .../src/pipeline/phases/reify/mod.rs | 82 +++++++++++-------- .../tests/arrow_function_scope_test.rs | 13 +++ 2 files changed, 60 insertions(+), 35 deletions(-) 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/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( From 8880a1034f9ea7b27192ce79045784ad733ac529 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 16:59:58 +0800 Subject: [PATCH 35/37] fix(transform): declare every class's pool after the imports Each class's constants and child view functions were declared just before that class (only files with a nested class put them after the imports), so a file's pool was split among its classes, and a long style that a later class shared was declared with the earlier class's part. ngtsc declares all of its constant pool's statements after the imports, before the first class, in pool order, and pools a shared style while the later class compiles. Every class's declarations now go there, joined into one edit in compile order, with a shared style declared with the class that shared it. When a statement comes before the last import (ngtsc moves the imports up), the declarations go before that statement. The TDZ hoisting of top-level bindings (#287) is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 132 +++++------------- .../tests/const_pool_file_wide_test.rs | 47 +++++++ .../tests/const_pool_long_strings_test.rs | 25 ++++ ...ration_test__ngfor_attribute_ordering.snap | 2 +- ...n_test__selector_attrs_const_emission.snap | 2 +- ...nt_without_imports_uses_dom_only_mode.snap | 2 +- 6 files changed, 113 insertions(+), 97 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 39a53f71e..39f896ece 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -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). /// @@ -3324,11 +3305,8 @@ pub fn transform_angular_file( 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 span, effective_start, class_body_end) - let mut class_positions: Vec<(Span, u32, u32)> = Vec::new(); - // The compiled classes nested in a function or block, by span: the - // file-level declarations of their compile go after the imports. - let mut nested_compiled: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + // (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(); @@ -3634,9 +3612,6 @@ pub fn transform_angular_file( declaration }; if !keeps_decorators { - if site.nested { - nested_compiled.insert(class.span); - } if site.namespace.is_some() { namespace_compiled.insert(class.span); } @@ -4276,15 +4251,7 @@ pub fn transform_angular_file( compilation_result.cmp_js.clone(), )); } - class_positions.push(( - class.span, - 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 @@ -4484,11 +4451,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class.span, - 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 { @@ -4610,11 +4573,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class.span, - 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.span, (property_assignments, String::new(), decls_after_class), @@ -4750,11 +4709,7 @@ pub fn transform_angular_file( } // NgModule: external_decls go AFTER the class (they reference the class name) - class_positions.push(( - class.span, - 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.span, (property_assignments, String::new(), external_decls), @@ -4848,11 +4803,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class.span, - 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.span, (property_assignments, String::new(), decls_after_class), @@ -4935,11 +4886,7 @@ pub fn transform_angular_file( &mut file_namespace_registry, ); - class_positions.push(( - class.span, - 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.span, (property_assignments, String::new(), decls_after_class), @@ -5056,7 +5003,7 @@ pub fn transform_angular_file( additional_imports.push_str(&file_namespace_registry.generate_import_statements()); } // Past the imports: where the added imports go, and the file-level - // declarations of a nested class. + // 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; @@ -5116,16 +5063,11 @@ pub fn transform_angular_file( edits.push(edit); } - // 5d. Class definition insertion edits. A nested class's constants go - // after the imports, like ngtsc's (a function declared later in the file - // may run before its statement), so the declarations of each position are - // joined into one edit, in the order the classes compiled. So do those of - // every class compiled before the last nested one: the nested class may - // reuse a constant one of them pooled. - let last_nested = class_positions.iter().rposition(|(span, ..)| nested_compiled.contains(span)); - let mut decls_by_start: Vec<(u32, String)> = Vec::new(); - for (index, (class_span, effective_start, class_body_end)) in class_positions.iter().enumerate() - { + // 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_span) { @@ -5137,18 +5079,10 @@ pub fn transform_angular_file( )); } - // Constants/child views before the class + // Constants/child views after the imports if !decls_before_class.is_empty() { - let start = if last_nested.is_some_and(|last| index <= last) { - after_imports - } else { - *effective_start - }; - let decls = format!("{decls_before_class}\n"); - match decls_by_start.iter_mut().find(|(other, _)| *other == start) { - Some((_, joined)) => joined.push_str(&decls), - None => decls_by_start.push((start, decls)), - } + decls_after_imports.push_str(decls_before_class); + decls_after_imports.push('\n'); } // Debug info/class metadata/HMR after the class @@ -5157,7 +5091,19 @@ pub fn transform_angular_file( } } } - edits.extend(decls_by_start.into_iter().map(|(start, decls)| Edit::insert(start, decls))); + 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. + let first_statement = parser_ret + .program + .body + .iter() + .find(|stmt| !matches!(stmt, Statement::ImportDeclaration(_))) + .map(|stmt| 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 @@ -6873,12 +6819,13 @@ 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>, @@ -6916,15 +6863,12 @@ fn apply_inherited_fixups<'a>( 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; } } 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 2af3b17fe..9266e986b 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 @@ -341,3 +341,50 @@ export class B {} ["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}"); +} 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/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']; From 9de21ff094ab3fc5e883f02b6e01b97d1fab3ef8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 18:36:09 +0800 Subject: [PATCH 36/37] fix(transform): put the pool before the decorators a class keeps When a statement comes before the last import, the pool goes before that statement. For `@Other() export class Plain {}` the statement's span starts at `export`, so the pool landed between the kept decorator and its class: `@Other() const _forTrack0 = ...`, a syntax error for esbuild and oxc. Start at the class's first decorator when it comes first. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 20 +++++++++++++- .../tests/const_pool_file_wide_test.rs | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 39f896ece..cab8f8aa8 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5095,12 +5095,30 @@ pub fn transform_angular_file( // 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| stmt.span().start); + .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)); } 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 9266e986b..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 @@ -388,3 +388,29 @@ 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}"); + } +} From 9191f1d60175628b11c140a1339829c0dce83a11 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 18:36:09 +0800 Subject: [PATCH 37/37] fix(dts): match a dotted class name only through its whole namespace path `A.C` dropped the `A` of `namespace A.B` and then looked for `C` like a bare name, which also searches nested namespaces, so it matched `A.B.C`: that class got both sets of members (duplicate identifiers) and `A.C` none. Look a dotted name up segment by segment, the last naming a class declared directly in the innermost namespace, and try each namespace of a name. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/dts_inject.rs | 52 +++++++++++++++++-- .../tests/nested_class_test.rs | 47 +++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/oxc_angular_compiler/src/dts_inject.rs b/crates/oxc_angular_compiler/src/dts_inject.rs index 21b7c0907..25b3fccaa 100644 --- a/crates/oxc_angular_compiler/src/dts_inject.rs +++ b/crates/oxc_angular_compiler/src/dts_inject.rs @@ -161,15 +161,15 @@ 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, ) -> Option<&'b ClassBody<'a>> { - let class_name = match class_name.split_once('.') { - Some((namespace, rest)) if namespace == ns.id.name.as_str() => rest, - Some(_) => return None, - None => class_name, - }; match &ns.body { TSNamespaceDeclarationBody::TSModuleBlock(block) => { find_class_body(&block.body, class_name) @@ -236,6 +236,48 @@ fn find_class_body<'a, 'b>( 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/tests/nested_class_test.rs b/crates/oxc_angular_compiler/tests/nested_class_test.rs index 41751c1e0..21bf43ffd 100644 --- a/crates/oxc_angular_compiler/tests/nested_class_test.rs +++ b/crates/oxc_angular_compiler/tests/nested_class_test.rs @@ -557,6 +557,53 @@ export declare namespace B.C { 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