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/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/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 + ); +}