diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 098a2da7e..a3364af5a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4427,10 +4427,16 @@ fn compile_component_partial<'a>( allocator: &'a Allocator, template: &'a str, metadata: &ComponentMetadata<'a>, + view_queries: &[R3QueryMetadata<'a>], + content_queries: &[R3QueryMetadata<'a>], pool_starting_index: u32, ) -> FullCompilationResult { - let inputs = - crate::partial::PartialComponentInputs { template, is_inline: metadata.template.is_some() }; + let inputs = crate::partial::PartialComponentInputs { + template, + is_inline: metadata.template.is_some(), + content_queries, + view_queries, + }; let cmp_expr = crate::partial::compile_declare_component_from_metadata(allocator, metadata, &inputs); let fac_expr = @@ -4481,7 +4487,14 @@ fn compile_component_full<'a>( // Partial declarations carry the template as a verbatim string and // let the linker re-parse at consumer build time. if matches!(options.compilation_mode, crate::CompilationMode::Partial) { - return Ok(compile_component_partial(allocator, template, metadata, pool_starting_index)); + return Ok(compile_component_partial( + allocator, + template, + metadata, + &view_queries, + &content_queries, + pool_starting_index, + )); } let mut diagnostics = Vec::new(); diff --git a/crates/oxc_angular_compiler/src/directive/metadata.rs b/crates/oxc_angular_compiler/src/directive/metadata.rs index a5365fd41..39da9cad7 100644 --- a/crates/oxc_angular_compiler/src/directive/metadata.rs +++ b/crates/oxc_angular_compiler/src/directive/metadata.rs @@ -94,6 +94,13 @@ pub struct R3QueryMetadata<'a> { /// Whether the query is signal-based. pub is_signal: bool, + + /// Whether a `Type` predicate was written `forwardRef(() => X)` in the + /// source and unwrapped during extraction. The partial emitter re-wraps + /// it (`convertFromMaybeForwardRefExpression` in render3/util.ts); the + /// full emitter leaves it bare because query calls run after class + /// initialization. + pub is_forward_ref: bool, } /// Query predicate type. @@ -118,6 +125,7 @@ impl<'a> R3QueryMetadata<'a> { read: None, is_static: false, is_signal: false, + is_forward_ref: false, } } } diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 70cfaf864..b869ac95a 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -898,6 +898,9 @@ struct QueryConfig<'a> { descendants: bool, /// Whether a `QueryList` only notifies when its contents change. emit_distinct_changes_only: bool, + /// Whether the predicate was a `forwardRef(() => X)` that extraction + /// unwrapped. Kept so the partial emitter can re-wrap it. + is_forward_ref: bool, } impl<'a> Default for QueryConfig<'a> { @@ -916,6 +919,7 @@ impl<'a> QueryConfig<'a> { predicate: None, is_static: false, read: None, + is_forward_ref: false, // For @ContentChildren, default is false; for all others, default is true descendants: decorator_name != "ContentChildren", emit_distinct_changes_only: true, @@ -957,6 +961,7 @@ fn parse_query_config<'a>( read: query.read, descendants: query.descendants, emit_distinct_changes_only: query.emit_distinct_changes_only, + is_forward_ref: query.is_forward_ref, }, Err(_) => QueryConfig::default_for(decorator_name), }; @@ -972,9 +977,12 @@ fn parse_query_config<'a>( let mut config = QueryConfig::default_for(decorator_name); - // The predicate: a string selector or a type/token. - // forwardRef isn't included in compiled output. - let node = try_unwrap_forward_ref(first_arg, None).unwrap_or(first_arg); + // The predicate: a string selector or a type/token. `forwardRef` is + // unwrapped for evaluation; `is_forward_ref` lets the partial emitter + // re-wrap it (upstream convertFromMaybeForwardRefExpression). + let unwrapped = try_unwrap_forward_ref(first_arg, None); + config.is_forward_ref = unwrapped.is_some(); + let node = unwrapped.unwrap_or(first_arg); config.predicate = match node { Expression::StringLiteral(lit) => { let mut list = Vec::new_in(&allocator); @@ -1091,6 +1099,7 @@ fn try_parse_signal_query<'a>( // Parse the predicate from the first argument let predicate_arg = call_expr.arguments.first()?; + let mut is_forward_ref = false; let predicate = match predicate_arg { // String selector: viewChild('myRef') Argument::StringLiteral(lit) => { @@ -1101,8 +1110,11 @@ fn try_parse_signal_query<'a>( // Type predicate: viewChild(TemplateRef) or viewChild(forwardRef(() => MyClass)) _ => { let expr = predicate_arg.to_expression(); - // Unwrap forwardRef if present - Angular doesn't include forwardRef in compiled output - let unwrapped_expr = try_unwrap_forward_ref(expr, consts).unwrap_or(expr); + // Unwrap forwardRef for evaluation; `is_forward_ref` records it so + // the partial emitter can re-wrap the predicate. + let unwrapped = try_unwrap_forward_ref(expr, consts); + is_forward_ref = unwrapped.is_some(); + let unwrapped_expr = unwrapped.unwrap_or(expr); // ngtsc emits a non-string locator as written (`WrappedNodeExpr`), // so fall back to the source text for what can't be converted. let output_expr = convert_oxc_expression(allocator, unwrapped_expr, source_text) @@ -1150,6 +1162,7 @@ fn try_parse_signal_query<'a>( read, is_static: false, // Signal queries are never static is_signal: true, + is_forward_ref, }, )) } @@ -1244,6 +1257,7 @@ pub(crate) fn extract_view_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1268,6 +1282,7 @@ pub(crate) fn extract_view_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1299,6 +1314,7 @@ pub(crate) fn extract_view_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1323,6 +1339,7 @@ pub(crate) fn extract_view_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1432,6 +1449,7 @@ pub(crate) fn extract_content_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1456,6 +1474,7 @@ pub(crate) fn extract_content_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1487,6 +1506,7 @@ pub(crate) fn extract_content_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -1511,6 +1531,7 @@ pub(crate) fn extract_content_queries_in<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref: config.is_forward_ref, }); } } @@ -2068,7 +2089,9 @@ fn decorator_query<'a>( let Some(first) = args.first().and_then(Argument::as_expression) else { return Err((format!("@{name} must have arguments"), span)); }; - let node = try_unwrap_forward_ref(first, Some(consts)).unwrap_or(first); + let unwrapped = try_unwrap_forward_ref(first, Some(consts)); + let is_forward_ref = unwrapped.is_some(); + let node = unwrapped.unwrap_or(first); let at_node = |message: String| (message, node.span()); let predicate = match evaluator.evaluate(node) { // Like ngtsc, a reference or a value that can't be statically @@ -2156,6 +2179,7 @@ fn decorator_query<'a>( read: config.read, is_static: config.is_static, is_signal: false, + is_forward_ref, }) } diff --git a/crates/oxc_angular_compiler/src/directive/query.rs b/crates/oxc_angular_compiler/src/directive/query.rs index cc23c77aa..023df5ce9 100644 --- a/crates/oxc_angular_compiler/src/directive/query.rs +++ b/crates/oxc_angular_compiler/src/directive/query.rs @@ -925,6 +925,7 @@ mod tests { emit_distinct_changes_only: false, is_static: false, is_signal: true, + is_forward_ref: false, read: None, }; @@ -967,6 +968,7 @@ mod tests { emit_distinct_changes_only: false, is_static: false, is_signal: true, + is_forward_ref: false, read: None, }; @@ -1017,6 +1019,7 @@ mod tests { emit_distinct_changes_only: false, is_static: false, is_signal: true, + is_forward_ref: false, read: None, }; @@ -1031,6 +1034,7 @@ mod tests { emit_distinct_changes_only: false, is_static: false, is_signal: true, + is_forward_ref: false, read: None, }; @@ -1088,6 +1092,7 @@ mod tests { emit_distinct_changes_only: true, is_static: false, is_signal: false, + is_forward_ref: false, read: None, }; @@ -1102,6 +1107,7 @@ mod tests { emit_distinct_changes_only: true, is_static: false, is_signal: false, + is_forward_ref: false, read: None, }; @@ -1151,6 +1157,7 @@ mod tests { emit_distinct_changes_only: true, is_static: false, is_signal: false, + is_forward_ref: false, read: None, }; @@ -1165,6 +1172,7 @@ mod tests { emit_distinct_changes_only: true, is_static: false, is_signal: false, + is_forward_ref: false, read: None, }; @@ -1215,6 +1223,7 @@ mod tests { emit_distinct_changes_only: false, is_static: false, is_signal: true, + is_forward_ref: false, read: None, }; diff --git a/crates/oxc_angular_compiler/src/output/oxc_converter.rs b/crates/oxc_angular_compiler/src/output/oxc_converter.rs index 2559639f5..6849c6785 100644 --- a/crates/oxc_angular_compiler/src/output/oxc_converter.rs +++ b/crates/oxc_angular_compiler/src/output/oxc_converter.rs @@ -464,6 +464,17 @@ fn convert_arrow_function_expression<'a>( arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, source_text: Option<&'a str>, ) -> Option> { + // `ArrowFunctionExpr`/`FnParam` can't express `async`, default values, + // or a rest parameter — emit the source verbatim (types stripped), as + // for destructured params and non-arrow function expressions. Dropping + // any of these would change runtime behavior (issue #512). + if arrow.r#async + || arrow.params.rest.is_some() + || arrow.params.items.iter().any(|p| p.initializer.is_some()) + { + return make_raw_source(allocator, source_text, arrow.span); + } + // Convert parameters let mut params = OxcVec::with_capacity_in(arrow.params.items.len(), &allocator); for param in &arrow.params.items { diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index f697562b0..c36bef332 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -41,7 +41,7 @@ use crate::component::{ ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, HostDirectiveMetadata, HostMetadata, TemplateDependency, TemplateDependencyKind, ViewEncapsulation, }; -use crate::directive::R3InputMetadata; +use crate::directive::{R3InputMetadata, R3QueryMetadata}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, @@ -55,7 +55,7 @@ use crate::r3::Identifiers; /// Inputs the partial Component emitter needs that aren't carried on the /// `ComponentMetadata` struct directly. Mirrors what the full-mode /// pipeline computes separately and threads in. -pub struct PartialComponentInputs<'a> { +pub struct PartialComponentInputs<'a, 'm> { /// The verbatim template source (inline literal or external file /// content). Partial mode emits this as a string literal — the linker /// re-parses it. @@ -63,17 +63,23 @@ pub struct PartialComponentInputs<'a> { /// Whether the template came from an inline `template: '...'` literal /// (`true`) versus an external `templateUrl` (`false`). pub is_inline: bool, + /// Content queries (`@ContentChild*` / `contentChild*` signal queries / + /// `queries:` content entries), emitted as `queries`. + pub content_queries: &'m [R3QueryMetadata<'a>], + /// View queries (`@ViewChild*` / `viewChild*` signal queries / + /// `queries:` view entries), emitted as `viewQueries`. + pub view_queries: &'m [R3QueryMetadata<'a>], } /// Emits the `ɵɵngDeclareComponent` call for a component's `ɵcmp` static. pub fn compile_declare_component_from_metadata<'a>( allocator: &'a Allocator, meta: &ComponentMetadata<'a>, - inputs: &PartialComponentInputs<'a>, + inputs: &PartialComponentInputs<'a, '_>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); - let min_version = compute_min_version(meta, inputs.template); + let min_version = compute_min_version(meta, inputs); entries.push(string_entry(allocator, "minVersion", min_version)); entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); @@ -141,6 +147,24 @@ pub fn compile_declare_component_from_metadata<'a>( )); } + // queries/viewQueries sit between providers and exportAs — where the + // upstream directive map (which createComponentDefinitionMap builds on) + // puts them (directive.ts:74-80). + if !inputs.content_queries.is_empty() { + entries.push(LiteralMapEntry::new( + Ident::from("queries"), + super::directive::compile_queries_array(allocator, inputs.content_queries), + false, + )); + } + if !inputs.view_queries.is_empty() { + entries.push(LiteralMapEntry::new( + Ident::from("viewQueries"), + super::directive::compile_queries_array(allocator, inputs.view_queries), + false, + )); + } + if !meta.export_as.is_empty() { let mut elements: Vec<'a, OutputExpression<'a>> = Vec::with_capacity_in(meta.export_as.len(), &allocator); @@ -351,25 +375,27 @@ pub fn compile_declare_factory_for_component<'a>( // ---- min version --------------------------------------------------------- -fn compute_min_version<'a>(meta: &ComponentMetadata<'a>, template: &str) -> &'static str { +fn compute_min_version<'a>( + meta: &ComponentMetadata<'a>, + inputs: &PartialComponentInputs<'a, '_>, +) -> &'static str { let mut min: &'static str = "14.0.0"; if meta.inputs.iter().any(|i| i.transform_function.is_some()) { min = "16.1.0"; } // Component bump: control-flow blocks in template. - if template_uses_blocks(template) { + if template_uses_blocks(inputs.template) { min = bump(min, "17.0.0"); } if needs_new_input_partial_output(&meta.inputs) { min = bump(min, "17.1.0"); } - // Signal queries — components carry these on the same path as - // directives, but the metadata struct doesn't include them - // directly. The dispatch layer that calls us will know if any query - // is signal-based; for now we approximate by checking inputs only. - // If signal queries land on ComponentMetadata later, bump 17.2.0 - // here. + // Signal-based queries emit `isSignal: true` in the query map, which + // linkers below 17.2.0 don't know (directive.ts:146-149). + if inputs.content_queries.iter().chain(inputs.view_queries.iter()).any(|q| q.is_signal) { + min = "17.2.0"; + } min } diff --git a/crates/oxc_angular_compiler/src/partial/directive.rs b/crates/oxc_angular_compiler/src/partial/directive.rs index 94275ad2a..9cca2f7c0 100644 --- a/crates/oxc_angular_compiler/src/partial/directive.rs +++ b/crates/oxc_angular_compiler/src/partial/directive.rs @@ -436,9 +436,12 @@ fn ident_pairs_to_string_map<'a>( // ---- queries -------------------------------------------------------------- -fn compile_queries_array<'a>( +/// `pub(crate)`: the component partial emitter reuses this — components and +/// directives share the query-map shape (upstream emits both through the +/// same `compileQuery`). +pub(crate) fn compile_queries_array<'a>( allocator: &'a Allocator, - queries: &Vec<'a, R3QueryMetadata<'a>>, + queries: &[R3QueryMetadata<'a>], ) -> OutputExpression<'a> { let mut entries: Vec<'a, OutputExpression<'a>> = Vec::with_capacity_in(queries.len(), &allocator); @@ -462,12 +465,15 @@ fn compile_query<'a>(allocator: &'a Allocator, q: &R3QueryMetadata<'a>) -> Outpu if q.first { entries.push(LiteralMapEntry::new(Ident::from("first"), bool_lit(allocator, true), false)); } - // predicate: type expression OR string array of selectors. (Forward-ref - // wrapping on a Type predicate is not tracked in the local metadata, - // so we emit the expression verbatim. If we add forward-ref tracking - // to QueryPredicate::Type, this is the place to wrap.) + // predicate: type expression OR string array of selectors. A `Type` + // predicate that was `forwardRef(() => X)` in source is emitted + // re-wrapped — upstream's convertFromMaybeForwardRefExpression — + // because the declaration is evaluated at class-definition time. let predicate_expr = match &q.predicate { - QueryPredicate::Type(expr) => expr.clone_in(allocator), + QueryPredicate::Type(expr) => { + let expr = expr.clone_in(allocator); + if q.is_forward_ref { wrap_forward_ref(allocator, expr) } else { expr } + } QueryPredicate::Selectors(selectors) => { let mut elements: Vec<'a, OutputExpression<'a>> = Vec::with_capacity_in(selectors.len(), &allocator); diff --git a/crates/oxc_angular_compiler/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 72778f6af..61bd42f83 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -128,7 +128,7 @@ pub fn extract_pipe_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, implicit_standalone: bool, - _source_text: Option<&'a str>, + source_text: Option<&'a str>, consts: Option<&StringConsts<'_>>, ) -> Option> { // Get the class name @@ -185,7 +185,7 @@ pub fn extract_pipe_metadata_in<'a>( } // Extract constructor dependencies for factory generation - metadata.deps = extract_constructor_deps(allocator, class, consts); + metadata.deps = extract_constructor_deps(allocator, class, source_text, consts); Some(metadata) } @@ -264,6 +264,7 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { fn extract_constructor_deps<'a>( allocator: &'a Allocator, class: &'a Class<'a>, + source_text: Option<&'a str>, consts: Option<&StringConsts<'_>>, ) -> Option>> { // Find the constructor method @@ -281,7 +282,7 @@ fn extract_constructor_deps<'a>( let mut deps = Vec::with_capacity_in(params.items.len(), &allocator); for param in ¶ms.items { - let dep = extract_param_dependency(allocator, param, consts); + let dep = extract_param_dependency(allocator, param, source_text, consts); deps.push(dep); } @@ -294,6 +295,7 @@ fn extract_constructor_deps<'a>( fn extract_param_dependency<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, + source_text: Option<&'a str>, consts: Option<&StringConsts<'_>>, ) -> R3DependencyMetadata<'a> { // Extract flags and @Inject token from decorators @@ -312,7 +314,7 @@ fn extract_param_dependency<'a>( if let Expression::CallExpression(call) = &decorator.expression { if let Some(arg) = call.arguments.first() { inject_token = - convert_oxc_expression(allocator, arg.to_expression(), None); + convert_oxc_expression(allocator, arg.to_expression(), source_text); } } } diff --git a/crates/oxc_angular_compiler/tests/arrow_metadata_emit_test.rs b/crates/oxc_angular_compiler/tests/arrow_metadata_emit_test.rs new file mode 100644 index 000000000..f1dbbd075 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/arrow_metadata_emit_test.rs @@ -0,0 +1,158 @@ +//! Arrow functions copied into decorator metadata must keep parameter +//! defaults, `async`, and rest parameters (issue #512). +//! +//! `convert_arrow_function_expression` can't express these in the output +//! AST, so it emits the source verbatim (types stripped) — the same +//! fallback as destructured params and non-arrow function expressions. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{CompilationMode, TransformOptions, transform_angular_file}; + +fn compile(source: &str, mode: CompilationMode) -> String { + let allocator = Allocator::default(); + let options = TransformOptions { compilation_mode: mode, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors(), "should not have errors, got: {:?}", result.diagnostics); + // Whitespace-insensitive comparisons — the emitters place it differently. + result.code.chars().filter(|c| !c.is_whitespace()).collect() +} + +const DIRECTIVE: &str = "import { Directive, Input } from '@angular/core'; +@Directive({ selector: '[d]' }) +export class D { + @Input({ transform: (v: string = 'a') => v.length }) a = 0; + @Input({ transform: async (v: any) => 1 }) b: any; +} +"; + +// `useFactory` arrows can legally use rest params (input transforms cannot — +// both oxc and ngtsc reject a spread first parameter). +const COMPONENT: &str = "import { Component } from '@angular/core'; +@Component({ + selector: 'c', + template: '', + providers: [ + { provide: 'T', useFactory: async (x: number = 1) => x }, + { provide: 'R', useFactory: (...args: number[]) => args.length }, + ], +}) +export class C {} +"; + +#[test] +fn full_mode_input_transform_keeps_default_value() { + let code = compile(DIRECTIVE, CompilationMode::Full); + assert!( + code.contains(r#""a",(v="a")=>v.length"#), + "default param must survive in inputs map, got:\n{code}" + ); + assert!( + code.contains(r#"{transform:(v="a")=>v.length}"#), + "default param must survive in setClassMetadata, got:\n{code}" + ); +} + +#[test] +fn full_mode_input_transform_keeps_async() { + let code = compile(DIRECTIVE, CompilationMode::Full); + assert!(code.contains(r#""b",async(v)=>1"#), "async must survive in inputs map, got:\n{code}"); + assert!( + code.contains(r#"{transform:async(v)=>1}"#), + "async must survive in setClassMetadata, got:\n{code}" + ); +} + +#[test] +fn full_mode_use_factory_keeps_async_default_and_rest() { + let code = compile(COMPONENT, CompilationMode::Full); + assert!( + code.contains("useFactory:async(x=1)=>x"), + "useFactory must keep async + default, got:\n{code}" + ); + assert!( + code.contains("useFactory:(...args)=>args.length"), + "rest param must survive, got:\n{code}" + ); +} + +#[test] +fn partial_mode_input_transform_keeps_default_and_async() { + let code = compile(DIRECTIVE, CompilationMode::Partial); + assert!( + code.contains(r#""a",(v="a")=>v.length"#), + "default param must survive in ngDeclareDirective inputs, got:\n{code}" + ); + assert!( + code.contains(r#"{transform:async(v)=>1}"#), + "async must survive in ngDeclareClassMetadata, got:\n{code}" + ); +} + +#[test] +fn partial_mode_use_factory_keeps_async_and_default() { + let code = compile(COMPONENT, CompilationMode::Partial); + assert!( + code.contains("useFactory:async(x=1)=>x"), + "useFactory must keep async + default in ngDeclareComponent, got:\n{code}" + ); +} + +/// A defaulted arrow inside `@Inject` on a pipe constructor must survive: +/// the raw-source fallback needs the file's source text, and the emitted +/// factory must carry the token rather than `ɵɵinvalidFactoryDep`. +const PIPE: &str = "import { Inject, Pipe, forwardRef } from '@angular/core'; +export class Token {} + +@Pipe({ name: 'p' }) +export class P { + constructor(@Inject(forwardRef((x = Token) => x)) t: unknown) {} +} +"; + +#[test] +fn pipe_inject_arrow_token_full_mode() { + let code = compile(PIPE, CompilationMode::Full); + assert!( + !code.contains("invalidFactoryDep"), + "factory must not degrade to invalidFactoryDep, got:\n{code}" + ); + assert!( + code.contains("(x=Token)=>x"), + "@Inject arrow token must survive verbatim, got:\n{code}" + ); +} + +#[test] +fn pipe_inject_arrow_token_partial_mode() { + let code = compile(PIPE, CompilationMode::Partial); + assert!( + !code.contains("invalidFactoryDep"), + "factory deps must not degrade to invalidFactoryDep, got:\n{code}" + ); + assert!( + code.contains("(x=Token)=>x"), + "@Inject arrow token must survive verbatim, got:\n{code}" + ); +} + +/// Plain arrows keep the structured emit — the fallback only kicks in when +/// the AST can't represent the arrow. +#[test] +fn plain_arrow_still_uses_structured_emit() { + let allocator = Allocator::default(); + let source = "import { Directive, Input } from '@angular/core'; +@Directive({ selector: '[d]' }) +export class D { + @Input({ transform: (v: string) => v.length }) a = 0; +} +"; + let options = TransformOptions::default(); + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors()); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + code.contains("(v)=>v.length") && !code.contains("v="), + "plain arrow should emit structurally, got:\n{}", + result.code + ); +} diff --git a/crates/oxc_angular_compiler/tests/partial_component_test.rs b/crates/oxc_angular_compiler/tests/partial_component_test.rs index 3a80996f5..794599618 100644 --- a/crates/oxc_angular_compiler/tests/partial_component_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_component_test.rs @@ -332,3 +332,166 @@ export class BarComponent {} let code = compile_partial(&allocator, "bar.component.ts", source); assert!(!code.contains("dependencies:"), "no imports → no dependencies field, got:\n{code}"); } + +// ---- queries (issue #513) -------------------------------------------------- + +/// Member-decorator queries on a component land in the partial declaration: +/// `queries` for content queries, `viewQueries` for view queries — the same +/// shape directives emit (upstream builds the component map on top of the +/// directive map). +#[test] +fn partial_component_emits_view_and_content_queries() { + let allocator = Allocator::default(); + let source = "import { Component, ViewChild, ContentChild, ElementRef } from '@angular/core'; + +@Component({ selector: 'c', template: '
' }) +export class C { + @ViewChild('a') q!: ElementRef; + @ContentChild('b') c!: unknown; +} +"; + let code = compile_partial(&allocator, "test.ts", source); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + r#"viewQueries:[{propertyName:"q",first:true,predicate:["a"],descendants:true}]"# + ), + "expected viewQueries map, got:\n{code}" + ); + assert!( + compact.contains( + r#"queries:[{propertyName:"c",first:true,predicate:["b"],descendants:true}]"# + ), + "expected queries map, got:\n{code}" + ); +} + +/// `queries:` declared in the decorator itself must be emitted too +/// (ngtsc emits both member decorators and the `queries:` field). +#[test] +fn partial_component_emits_decorator_queries_field() { + let allocator = Allocator::default(); + let source = "import { Component, ViewChildren, QueryList, ElementRef } from '@angular/core'; + +@Component({ + selector: 'c', + template: '
', + queries: { items: new ViewChildren('a') }, +}) +export class C { + items!: QueryList; +} +"; + let code = compile_partial(&allocator, "test.ts", source); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact + .contains(r#"viewQueries:[{propertyName:"items",predicate:["a"],descendants:true}]"#), + "expected decorator queries in viewQueries, got:\n{code}" + ); +} + +/// Signal queries (`viewChild(...)`) emit `isSignal: true` and bump +/// `minVersion` to 17.2.0 — the linker version that understands them +/// (directive.ts:146-149). +#[test] +fn partial_component_signal_queries_bump_min_version() { + let allocator = Allocator::default(); + let source = + "import { Component, viewChild, contentChildren, ElementRef } from '@angular/core'; + +@Component({ selector: 'c', template: '
' }) +export class C { + q = viewChild('a'); + cs = contentChildren('b'); +} +"; + let code = compile_partial(&allocator, "test.ts", source); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains(r#"viewQueries:[{propertyName:"q",first:true,predicate:["a"],descendants:true,isSignal:true}]"#), + "expected signal viewQuery, got:\n{code}" + ); + // `descendants` is omitted: it defaults to false for contentChildren + // (query_functions.ts defaultDescendantsValue — true for every query + // function except contentChildren). + assert!( + compact.contains(r#"queries:[{propertyName:"cs",predicate:["b"],isSignal:true}]"#), + "expected signal content query, got:\n{code}" + ); + assert!(compact.contains(r#"minVersion:"17.2.0""#), "expected minVersion 17.2.0, got:\n{code}"); +} + +/// A `forwardRef(() => Child)` predicate must stay wrapped in the partial +/// output: the declaration is evaluated at class-definition time, so a bare +/// `Child` would hit the TDZ when `Child` is declared later. Upstream +/// re-wraps via `convertFromMaybeForwardRefExpression` (util.ts). +#[test] +fn partial_component_preserves_forward_ref_query_predicates() { + let allocator = Allocator::default(); + let source = "import { Component, ViewChild, forwardRef } from '@angular/core'; +import { ChildDir } from './child'; + +@Component({ selector: 'c', template: '
' }) +export class C { + @ViewChild(forwardRef(() => ChildDir)) q!: ChildDir; +} +"; + let code = compile_partial(&allocator, "test.ts", source); + assert!( + code.contains("predicate:i0.forwardRef(function") && code.contains("return ChildDir"), + "expected forwardRef-wrapped predicate, got:\n{code}" + ); +} + +/// Same for signal queries — `viewChild(forwardRef(() => T))` keeps its +/// wrapper and still emits `isSignal: true`. +#[test] +fn partial_component_signal_query_preserves_forward_ref() { + let allocator = Allocator::default(); + let source = "import { Component, viewChild, forwardRef } from '@angular/core'; +import { ChildDir } from './child'; + +@Component({ selector: 'c', template: '
' }) +export class C { + q = viewChild(forwardRef(() => ChildDir)); +} +"; + let code = compile_partial(&allocator, "test.ts", source); + assert!( + code.contains("predicate:i0.forwardRef(function") && code.contains("isSignal:true"), + "expected forwardRef-wrapped signal predicate, got:\n{code}" + ); +} + +/// Round-trip: the linked ɵɵdefineComponent must carry the queries so +/// `@ViewChild`/`@ContentChild` actually resolve at runtime (the issue's +/// user-visible symptom). +#[test] +fn linked_component_gets_queries() { + let allocator = Allocator::default(); + let source = "import { Component, ViewChild, ContentChild, ElementRef } from '@angular/core'; + +@Component({ selector: 'c', template: '
' }) +export class C { + @ViewChild('a') q!: ElementRef; + @ContentChild('b') c!: unknown; +} +"; + let code = compile_partial(&allocator, "test.ts", source); + // `.ts` — the partial output still carries TS syntax (field type + // annotations); a bundler would strip types before the linker sees it. + let linked = link(&allocator, &code, "test.ts"); + assert!(linked.linked, "linker should accept the partial output, emitted:\n{code}"); + let compact: String = linked.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains("viewQuery:"), + "linked output should wire the view query, got:\n{}", + linked.code + ); + assert!( + compact.contains("contentQueries:"), + "linked output should wire the content query, got:\n{}", + linked.code + ); +}