diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index e4c7fd7b1..d0d8d3f43 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -19,8 +19,8 @@ use crate::directive::{ try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, }; use crate::output::ast::{ - ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, - LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr, + ArrowFunctionBody, ArrowFunctionExpr, InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, + LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr, }; use crate::output::oxc_converter::{ convert_oxc_expression, convert_plain_properties, is_plain_property, make_raw_source, @@ -781,7 +781,23 @@ fn build_signal_query_decorator<'a>( // query's `WrappedNodeExpr` fallback, only the raw source can stand in. let first = call.arguments.first()?; let predicate = match first.as_expression() { - Some(expr) => convert_oxc_expression(allocator, expr, source_text)?, + // Like ngtsc's `memberMetadataFromSignalQuery`, a predicate other than a string or a + // call (a class) is wrapped in `forwardRef(() => )`: `setClassMetadata` runs + // as soon as the class is defined, unlike the query initializer, so a class declared + // later in the file would be read before it is initialized. + Some(expr) => { + let predicate = convert_oxc_expression(allocator, expr, source_text)?; + let string_or_call = match expr { + Expression::StringLiteral(_) | Expression::CallExpression(_) => true, + Expression::TemplateLiteral(template) => template.expressions.is_empty(), + _ => false, + }; + if string_or_call { + predicate + } else { + forward_ref(allocator, namespace_registry, predicate) + } + } None => make_raw_source(allocator, source_text, first.span())?, }; let mut args = AllocVec::new_in(&allocator); @@ -810,30 +826,45 @@ fn build_signal_query_decorator<'a>( Some(build_core_decorator(allocator, namespace_registry, decorator_name, args)) } -/// Build `{ type: i0., args: [""] }` for a decorator taking a single string. -fn build_core_decorator_with_string_arg<'a>( +/// `i0.forwardRef(() => )`. +fn forward_ref<'a>( allocator: &'a Allocator, namespace_registry: &mut NamespaceRegistry<'a>, - decorator_name: &'static str, - arg: Ident<'a>, + expression: OutputExpression<'a>, ) -> OutputExpression<'a> { + let arrow = OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { + params: AllocVec::new_in(&allocator), + body: ArrowFunctionBody::Expression(Box::new_in(expression, &allocator)), + source_span: None, + }, + &allocator, + )); let mut args = AllocVec::new_in(&allocator); - args.push(string_literal(allocator, arg)); - build_core_decorator(allocator, namespace_registry, decorator_name, args) + args.push(arrow); + OutputExpression::InvokeFunction(Box::new_in( + InvokeFunctionExpr { + fn_expr: Box::new_in( + core_member(allocator, namespace_registry, "forwardRef"), + &allocator, + ), + args, + pure: false, + optional: false, + source_span: None, + }, + &allocator, + )) } -/// Build a synthetic Angular core decorator metadata object: `{ type: i0., args: [...] }`. -/// The decorator type is referenced through the `@angular/core` namespace import (`i0`), since a -/// component using signal APIs imports `input`/`output`/… rather than the `Input`/`Output`/query -/// decorators themselves. -fn build_core_decorator<'a>( +/// `i0.`: a member of the `@angular/core` namespace import. +fn core_member<'a>( allocator: &'a Allocator, namespace_registry: &mut NamespaceRegistry<'a>, - decorator_name: &'static str, - args: AllocVec<'a, OutputExpression<'a>>, + name: &'static str, ) -> OutputExpression<'a> { let core_namespace = namespace_registry.get_or_assign(&Ident::from(crate::r3::CORE)); - let type_expr = OutputExpression::ReadProp(Box::new_in( + OutputExpression::ReadProp(Box::new_in( ReadPropExpr { receiver: Box::new_in( OutputExpression::ReadVar(Box::new_in( @@ -842,12 +873,37 @@ fn build_core_decorator<'a>( )), &allocator, ), - name: Ident::from(decorator_name), + name: Ident::from(name), optional: false, source_span: None, }, &allocator, - )); + )) +} + +/// Build `{ type: i0., args: [""] }` for a decorator taking a single string. +fn build_core_decorator_with_string_arg<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + arg: Ident<'a>, +) -> OutputExpression<'a> { + let mut args = AllocVec::new_in(&allocator); + args.push(string_literal(allocator, arg)); + build_core_decorator(allocator, namespace_registry, decorator_name, args) +} + +/// Build a synthetic Angular core decorator metadata object: `{ type: i0., args: [...] }`. +/// The decorator type is referenced through the `@angular/core` namespace import (`i0`), since a +/// component using signal APIs imports `input`/`output`/… rather than the `Input`/`Output`/query +/// decorators themselves. +fn build_core_decorator<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + args: AllocVec<'a, OutputExpression<'a>>, +) -> OutputExpression<'a> { + let type_expr = core_member(allocator, namespace_registry, decorator_name); let mut entries = AllocVec::new_in(&allocator); entries.push(LiteralMapEntry::new(Ident::from("type"), type_expr, false)); diff --git a/crates/oxc_angular_compiler/tests/signal_query_metadata_forward_ref_test.rs b/crates/oxc_angular_compiler/tests/signal_query_metadata_forward_ref_test.rs new file mode 100644 index 000000000..f370fe708 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/signal_query_metadata_forward_ref_test.rs @@ -0,0 +1,81 @@ +//! In `setClassMetadata`, a signal query's predicate (`contentChildren(Pane)`) is +//! wrapped in `forwardRef(() => Pane)` unless it is a string or a call, as +//! ngtsc's `memberMetadataFromSignalQuery` does: `setClassMetadata` runs as soon +//! as the class is defined, so a class declared later in the file would be read +//! before it is initialized (a TDZ error in development builds). The expected +//! metadata is `@angular/compiler-cli` 21.0.1's for the same classes. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +fn compile(source: &str) -> String { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "test.ts", + source, + Some(&TransformOptions::default()), + None, + ); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + result + .code + .chars() + .filter(|c| !c.is_whitespace()) + .map(|c| if c == '\'' { '"' } else { c }) + .collect() +} + +fn assert_contains(code: &str, expected: &str) { + assert!(code.contains(expected), "Expected `{expected}` in:\n{code}"); +} + +#[test] +fn query_of_a_class_declared_later_is_a_forward_ref() { + let code = compile( + r" +import { Component, contentChildren, viewChild } from '@angular/core'; +@Component({ selector: 'd-parent', template: '' }) +export class DParent { + panes = contentChildren(DPane); + first = viewChild(DPane); +} +@Component({ selector: 'd-pane', template: '' }) +export class DPane {} +", + ); + assert_contains( + &code, + "panes:[{type:i0.ContentChildren,args:[i0.forwardRef(()=>DPane),{isSignal:true}]}]", + ); + assert_contains( + &code, + "first:[{type:i0.ViewChild,args:[i0.forwardRef(()=>DPane),{isSignal:true}]}]", + ); +} + +#[test] +fn query_of_a_class_declared_earlier_is_a_forward_ref_too() { + let code = compile( + r" +import { Component, ContentChildren, ElementRef, QueryList, ViewChildren, contentChild, viewChildren } from '@angular/core'; +@Component({ selector: 'f-pane', template: '' }) +export class FPane {} +@Component({ selector: 'f-parent', template: '' }) +export class FParent { + one = contentChild(FPane, { read: ElementRef }); + views = viewChildren('ref'); + @ContentChildren(FPane) legacy!: QueryList; + @ViewChildren('a, b') strings!: QueryList; +} +", + ); + assert_contains( + &code, + "one:[{type:i0.ContentChild,args:[i0.forwardRef(()=>FPane),{...{read:ElementRef},isSignal:true}]}]", + ); + // a string predicate, and decorator queries, stay as written + assert_contains(&code, r#"views:[{type:i0.ViewChildren,args:["ref",{isSignal:true}]}]"#); + assert_contains(&code, "legacy:[{type:ContentChildren,args:[FPane]}]"); + assert_contains(&code, r#"strings:[{type:ViewChildren,args:["a,b"]}]"#); +}