From add60c96df81e03172619e514f4bb0eb6c4209f0 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Thu, 24 Sep 2026 08:18:24 +0100 Subject: [PATCH] fix(queries): compile decorator `queries:` and match ngtsc's query options `@Directive({ queries: { el: new ViewChild('el') } })` (and `@Component`) was ignored, so those queries never ran. It's now parsed like ngtsc's `extractQueriesFromDecorator`: `new ViewChild/ViewChildren/ContentChild/ ContentChildren(...)` imported from `@angular/core` (directly or through a namespace import), appended after the member queries, with ngtsc's diagnostics word for word (not an object literal, not a query type, missing/extra arguments, non-object options, non-boolean `descendants`/`static`/ `emitDistinctChangesOnly`, uninterpretable predicates, and a signal query declared for the same property). Member query decorators now match ngtsc too: - `descendants` and `emitDistinctChangesOnly` are honoured on `@ViewChild`/ `@ViewChildren` (they were hard-coded), which changes the emitted flags - a predicate referencing a same-file string const (`@ViewChild(SELECTOR)`) compiles to that selector rather than to a type predicate - `forwardRef(function () { return X; })`, and `forwardRef` behind parentheses or `as`, is unwrapped like the arrow form `extract_class_queries` returns a class's queries as they're compiled; the compiler and the NAPI `extractComponentMetadataSync` both use it, so the metadata API reports the same queries. --- .../src/component/transform.rs | 12 +- .../src/directive/decorator.rs | 29 +- .../src/directive/evaluator.rs | 11 +- .../src/directive/metadata.rs | 28 +- .../oxc_angular_compiler/src/directive/mod.rs | 12 +- .../src/directive/property_decorators.rs | 454 ++++++++++++++--- crates/oxc_angular_compiler/src/lib.rs | 7 +- .../tests/decorator_metadata_ngtsc_test.rs | 2 +- .../fixtures/decorator_metadata_ngtsc.json | 456 ++++++++++++++++++ napi/angular-compiler/src/lib.rs | 13 +- .../test/extract-component-metadata.test.ts | 39 ++ 11 files changed, 956 insertions(+), 107 deletions(-) create mode 100644 napi/angular-compiler/test/extract-component-metadata.test.ts diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index abb63afc1..bd5ada599 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -41,7 +41,7 @@ use crate::class_metadata::{ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, - decorator_io_errors, extract_content_queries, extract_directive_metadata, extract_view_queries, + decorator_io_errors, extract_class_queries, extract_directive_metadata, find_directive_decorator, find_directive_decorator_span, generate_directive_definitions, }; use crate::dts; @@ -2634,11 +2634,11 @@ pub fn transform_angular_file( let template = allocator.alloc_str(&template_string); // 4.5 Extract view queries from the class (for @ViewChild/@ViewChildren) // These need to be passed to compile_component_full so predicates can be pooled - let view_queries = extract_view_queries(allocator, class, Some(source)); - - // 4.6 Extract content queries from the class (for @ContentChild/@ContentChildren) - // Signal-based queries (contentChild(), contentChildren()) are also detected here - let content_queries = extract_content_queries(allocator, class, Some(source)); + // 4.5/4.6 View and content queries: member decorators and signal + // queries, then `queries:` in the decorator, as ngtsc orders them. + // Predicates are pooled by compile_component_full. + let (view_queries, content_queries) = + extract_class_queries(allocator, class, Some(source), &string_consts); // Collect content query property names for .d.ts generation // (before content_queries is moved into compile_component_full) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 2f7bed371..2415872db 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -181,7 +181,7 @@ pub fn extract_directive_metadata<'a>( } // Extract @Input/@Output/@HostBinding/@HostListener from class members - builder = builder.extract_from_class(allocator, class, source_text); + builder = builder.extract_from_class_in(allocator, class, source_text, Some(consts)); // Detect if ngOnChanges lifecycle hook is implemented // Similar to Angular's: const usesOnChanges = members.some(member => ...) @@ -206,6 +206,20 @@ pub fn extract_directive_metadata<'a>( // The builder already has host data from extract_from_class, we need to merge the decorator host let mut metadata = builder.build()?; + // `queries:` from the decorator come after the member queries, as in ngtsc. + if let Some(config) = config_obj { + let queries = super::parse_decorator_queries( + allocator, + config, + class, + source_text, + consts, + "Directive", + ); + metadata.view_queries.extend(queries.view); + metadata.queries.extend(queries.content); + } + if let Some(io) = io { let fields = std::mem::replace(&mut metadata.inputs, Vec::new_in(&allocator)); metadata.inputs = @@ -910,10 +924,10 @@ pub(crate) fn angular_decorator_config<'a>( Some((config, name)) } -/// The first error ngtsc raises for the inputs and outputs of a `@Component` / -/// `@Directive` on `class`, in the order it checks them -/// (`extractDirectiveMetadata`): `inputs:`, `@Input` members, `outputs:`, then -/// output members. ngtsc stops at the first one. +/// The first error ngtsc raises for the inputs, outputs and queries of a +/// `@Component` / `@Directive` on `class`, in the order it checks them +/// (`extractDirectiveMetadata`): `inputs:`, `@Input` members, `outputs:`, +/// output members, then `queries:`. ngtsc stops at the first one. pub fn decorator_io_errors<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -980,12 +994,17 @@ pub fn decorator_io_errors<'a>( }) }) }; + let queries = || { + let config = config?; + super::parse_decorator_queries(allocator, config, class, None, consts, decorator_name).error + }; io.as_ref() .and_then(|io| io.input_error.clone()) .or_else(input_members) .or_else(|| io.as_ref().and_then(|io| io.output_error.clone())) .or_else(output_members) + .or_else(queries) .into_iter() .collect() } diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 53f2993ab..0b8f53ee6 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -37,9 +37,11 @@ pub(crate) struct FileScope<'a> { types: HashSet<&'a str>, } -/// An import binding: unless it's a namespace import, the name it's exported under. +/// An import binding: the module it comes from and, unless it's a namespace +/// import, the name it's exported under. #[derive(Clone, Copy)] pub(crate) struct Import<'a> { + pub module: &'a str, pub imported: Option<&'a str>, } @@ -49,6 +51,7 @@ impl<'a> FileScope<'a> { for stmt in &program.body { match stmt { Statement::ImportDeclaration(import) => { + let module = import.source.value.as_str(); for spec in import.specifiers.iter().flatten() { let (local, imported) = match spec { ImportDeclarationSpecifier::ImportSpecifier(s) => { @@ -66,7 +69,7 @@ impl<'a> FileScope<'a> { (s.local.name.as_str(), None) } }; - scope.imports.insert(local, Import { imported }); + scope.imports.insert(local, Import { module, imported }); } } Statement::ExportDeclaration(export) => { @@ -167,6 +170,10 @@ impl<'a> FileScope<'a> { self.exported.insert(id); } } + + pub(crate) fn import(&self, name: &str) -> Option> { + self.imports.get(name).copied() + } } /// A function definition ngtsc's `getDefinitionOfFunction` accepts. diff --git a/crates/oxc_angular_compiler/src/directive/metadata.rs b/crates/oxc_angular_compiler/src/directive/metadata.rs index 9c791d85a..d3486eaae 100644 --- a/crates/oxc_angular_compiler/src/directive/metadata.rs +++ b/crates/oxc_angular_compiler/src/directive/metadata.rs @@ -411,10 +411,22 @@ impl<'a> R3DirectiveMetadataBuilder<'a> { /// # Returns /// The builder with all extracted metadata added. pub fn extract_from_class( + self, + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + ) -> Self { + self.extract_from_class_in(allocator, class, source_text, None) + } + + /// [`Self::extract_from_class`], resolving query predicates that reference + /// same-file consts (`@ViewChild(SELECTOR)`) the way ngtsc does. + pub(crate) fn extract_from_class_in( mut self, allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, + consts: Option<&super::StringConsts<'a>>, ) -> Self { // Extract inputs from @Input decorators let inputs = @@ -430,15 +442,23 @@ impl<'a> R3DirectiveMetadataBuilder<'a> { } // Extract view queries from @ViewChild/@ViewChildren - let view_queries = - super::property_decorators::extract_view_queries(allocator, class, source_text); + let view_queries = super::property_decorators::extract_view_queries_in( + allocator, + class, + source_text, + consts, + ); for query in view_queries { self = self.add_view_query(query); } // Extract content queries from @ContentChild/@ContentChildren - let content_queries = - super::property_decorators::extract_content_queries(allocator, class, source_text); + let content_queries = super::property_decorators::extract_content_queries_in( + allocator, + class, + source_text, + consts, + ); for query in content_queries { self = self.add_query(query); } diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index ddde07cd6..c01cf78e9 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -30,7 +30,9 @@ pub use decorator::{ StringConsts, collect_string_consts, decorator_io_errors, extract_directive_metadata, find_directive_decorator_span, }; -pub(crate) use decorator::{extract_string_value, resolve_template_literal}; +pub(crate) use decorator::{ + angular_decorator_config, extract_string_value, resolve_template_literal, +}; pub(crate) use decorator::{ merge_by_class_property, parse_decorator_io, resolve_member_transforms, }; @@ -40,11 +42,11 @@ pub use metadata::{ R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; pub use property_decorators::{ - extract_content_queries, extract_host_bindings, extract_host_listeners, extract_input_metadata, - extract_output_metadata, extract_view_queries, + extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, + extract_input_metadata, extract_output_metadata, extract_view_queries, }; pub(crate) use property_decorators::{ - try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, - unwrap_initializer_api_expr, + parse_decorator_queries, try_parse_signal_input, try_parse_signal_model, + try_parse_signal_output, unwrap_initializer_api_expr, }; pub use query::{create_content_queries_function, create_view_queries_function}; diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index b5676fe2b..3604a90f9 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -93,42 +93,33 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { /// Try to unwrap a forwardRef call and extract the inner expression. /// -/// For `forwardRef(() => MyClass)`, returns `Some(MyClass expression)`. -/// For non-forwardRef expressions, returns None. +/// Mirrors ngtsc's `tryUnwrapForwardRef`: `forwardRef(() => X)`, +/// `forwardRef(function () { return X; })`, looking through parentheses and +/// `as` casts. Returns `None` for anything else. fn try_unwrap_forward_ref<'a>(expr: &'a Expression<'a>) -> Option<&'a Expression<'a>> { - let call = match expr { - Expression::CallExpression(call) => call, + let Expression::CallExpression(call) = unwrap_expression(expr) else { return None }; + let callee = match &call.callee { + Expression::Identifier(id) => id.name.as_str(), + Expression::StaticMemberExpression(m) => m.property.name.as_str(), _ => return None, }; - - // Check if callee is forwardRef - let is_forward_ref = - matches!(&call.callee, Expression::Identifier(id) if id.name == "forwardRef"); - if !is_forward_ref { + if callee != "forwardRef" || call.arguments.len() != 1 { return None; } - - // Get the first argument (should be an arrow function) - let first_arg = call.arguments.first()?; - let arrow = match first_arg { - Argument::ArrowFunctionExpression(arrow) => arrow, - _ => return None, - }; - - // Expression body: () => MyClass - if let Some(expr) = arrow.get_expression() { - return Some(expr); - } - // Block body: () => { return MyClass; } - if let Some(body) = arrow.get_function_body() { - if body.statements.len() == 1 { - if let oxc_ast::ast::Statement::ReturnStatement(ret) = &body.statements[0] { - return ret.argument.as_ref(); + let body = match unwrap_expression(call.arguments[0].as_expression()?) { + Expression::ArrowFunctionExpression(arrow) => { + if let Some(expr) = arrow.get_expression() { + return Some(expr); } + arrow.get_function_body()? } + Expression::FunctionExpression(f) => &**f.body.as_ref()?, + _ => return None, + }; + match body.statements.as_slice() { + [oxc_ast::ast::Statement::ReturnStatement(ret)] => ret.argument.as_ref(), + _ => None, } - - None } // ============================================================================ @@ -750,13 +741,15 @@ struct QueryConfig<'a> { is_static: bool, /// Expression to read from matched elements. read: Option>, - /// Whether to include descendants (for content queries). + /// Whether to include descendants. descendants: bool, + /// Whether a `QueryList` only notifies when its contents change. + emit_distinct_changes_only: bool, } impl<'a> Default for QueryConfig<'a> { fn default() -> Self { - Self { predicate: None, is_static: false, read: None, descendants: true } + Self::default_for("") } } @@ -772,6 +765,7 @@ impl<'a> QueryConfig<'a> { read: None, // For @ContentChildren, default is false; for all others, default is true descendants: decorator_name != "ContentChildren", + emit_distinct_changes_only: true, } } } @@ -791,38 +785,40 @@ fn parse_query_config<'a>( decorator: &'a Decorator<'a>, decorator_name: &str, source_text: Option<&'a str>, + consts: Option<&super::StringConsts<'a>>, ) -> QueryConfig<'a> { let Expression::CallExpression(call) = &decorator.expression else { return QueryConfig::default_for(decorator_name); }; - let Some(first_arg) = call.arguments.first() else { + let Some(first_arg) = call.arguments.first().and_then(Argument::as_expression) else { return QueryConfig::default_for(decorator_name); }; let mut config = QueryConfig::default_for(decorator_name); - // Parse predicate from first argument - match first_arg { - // @ViewChild('refName') - string selector - Argument::StringLiteral(lit) => { - let mut selectors = Vec::new_in(&allocator); - selectors.push(lit.value.clone().into()); - config.predicate = Some(QueryPredicate::Selectors(selectors)); - } - - // Other expressions (identifiers, member expressions, forwardRef calls, etc.) - _ => { - let expr = first_arg.to_expression(); - // Unwrap forwardRef if present - Angular doesn't include forwardRef in compiled output - let unwrapped_expr = try_unwrap_forward_ref(expr).unwrap_or(expr); - if let Some(output_expr) = - convert_oxc_expression(allocator, unwrapped_expr, source_text) - { - config.predicate = Some(QueryPredicate::Type(output_expr)); + // The predicate: a string selector, a string array, or a type/token. + // forwardRef isn't included in compiled output. + let node = try_unwrap_forward_ref(first_arg).unwrap_or(first_arg); + let selectors = match (consts, node) { + (Some(consts), _) => match super::evaluator::Evaluator::new(consts).evaluate(node) { + super::evaluator::Value::String(s) => Some(std::vec![s]), + super::evaluator::Value::Array(items) => { + items.iter().map(|i| i.as_str().map(str::to_string)).collect() } + _ => None, + }, + (None, Expression::StringLiteral(lit)) => Some(std::vec![lit.value.to_string()]), + _ => None, + }; + config.predicate = match selectors { + Some(selectors) => { + let mut list = Vec::new_in(&allocator); + list.extend(selectors.iter().map(|s| Ident::from(allocator.alloc_str(s)))); + Some(QueryPredicate::Selectors(list)) } - } + None => convert_oxc_expression(allocator, node, source_text).map(QueryPredicate::Type), + }; // Parse options from second argument if present if let Some(second_arg) = call.arguments.get(1) { @@ -847,6 +843,10 @@ fn parse_query_config<'a>( config.descendants = extract_boolean_value(&prop.value).unwrap_or(default); } + "emitDistinctChangesOnly" => { + config.emit_distinct_changes_only = + extract_boolean_value(&prop.value).unwrap_or(true); + } _ => {} } } @@ -1049,6 +1049,17 @@ pub fn extract_view_queries<'a>( allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, +) -> Vec<'a, R3QueryMetadata<'a>> { + extract_view_queries_in(allocator, class, source_text, None) +} + +/// [`extract_view_queries`], resolving predicates that reference same-file +/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does. +pub(crate) fn extract_view_queries_in<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: Option<&super::StringConsts<'a>>, ) -> Vec<'a, R3QueryMetadata<'a>> { // Use separate vectors to match Angular's ordering approach. // Angular groups queries by type, maintaining declaration order within each group: @@ -1081,15 +1092,20 @@ pub fn extract_view_queries<'a>( // Check for decorator-based queries (@ViewChild, @ViewChildren) if let Some(decorator) = find_decorator_by_name(&prop.decorators, "ViewChild") { if let Some(property_name) = get_property_key_name(&prop.key) { - let config = - parse_query_config(allocator, decorator, "ViewChild", source_text); + let config = parse_query_config( + allocator, + decorator, + "ViewChild", + source_text, + consts, + ); if let Some(predicate) = config.predicate { view_child_queries.push(R3QueryMetadata { property_name, first: true, predicate, - descendants: true, - emit_distinct_changes_only: true, + descendants: config.descendants, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1100,15 +1116,20 @@ pub fn extract_view_queries<'a>( find_decorator_by_name(&prop.decorators, "ViewChildren") { if let Some(property_name) = get_property_key_name(&prop.key) { - let config = - parse_query_config(allocator, decorator, "ViewChildren", source_text); + let config = parse_query_config( + allocator, + decorator, + "ViewChildren", + source_text, + consts, + ); if let Some(predicate) = config.predicate { view_children_queries.push(R3QueryMetadata { property_name, first: false, predicate, - descendants: true, - emit_distinct_changes_only: true, + descendants: config.descendants, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1123,15 +1144,20 @@ pub fn extract_view_queries<'a>( // Check for decorator-based queries on setters/getters if let Some(decorator) = find_decorator_by_name(&method.decorators, "ViewChild") { if let Some(property_name) = get_property_key_name(&method.key) { - let config = - parse_query_config(allocator, decorator, "ViewChild", source_text); + let config = parse_query_config( + allocator, + decorator, + "ViewChild", + source_text, + consts, + ); if let Some(predicate) = config.predicate { view_child_queries.push(R3QueryMetadata { property_name, first: true, predicate, - descendants: true, - emit_distinct_changes_only: true, + descendants: config.descendants, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1142,15 +1168,20 @@ pub fn extract_view_queries<'a>( find_decorator_by_name(&method.decorators, "ViewChildren") { if let Some(property_name) = get_property_key_name(&method.key) { - let config = - parse_query_config(allocator, decorator, "ViewChildren", source_text); + let config = parse_query_config( + allocator, + decorator, + "ViewChildren", + source_text, + consts, + ); if let Some(predicate) = config.predicate { view_children_queries.push(R3QueryMetadata { property_name, first: false, predicate, - descendants: true, - emit_distinct_changes_only: true, + descendants: config.descendants, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1192,6 +1223,17 @@ pub fn extract_content_queries<'a>( allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, +) -> Vec<'a, R3QueryMetadata<'a>> { + extract_content_queries_in(allocator, class, source_text, None) +} + +/// [`extract_content_queries`], resolving predicates that reference same-file +/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does. +pub(crate) fn extract_content_queries_in<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: Option<&super::StringConsts<'a>>, ) -> Vec<'a, R3QueryMetadata<'a>> { // Use separate vectors to match Angular's ordering approach. // Angular groups queries by type, maintaining declaration order within each group: @@ -1224,15 +1266,20 @@ pub fn extract_content_queries<'a>( // Check for decorator-based queries (@ContentChild, @ContentChildren) if let Some(decorator) = find_decorator_by_name(&prop.decorators, "ContentChild") { if let Some(property_name) = get_property_key_name(&prop.key) { - let config = - parse_query_config(allocator, decorator, "ContentChild", source_text); + let config = parse_query_config( + allocator, + decorator, + "ContentChild", + source_text, + consts, + ); if let Some(predicate) = config.predicate { content_child_queries.push(R3QueryMetadata { property_name, first: true, predicate, descendants: config.descendants, - emit_distinct_changes_only: true, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1248,6 +1295,7 @@ pub fn extract_content_queries<'a>( decorator, "ContentChildren", source_text, + consts, ); if let Some(predicate) = config.predicate { content_children_queries.push(R3QueryMetadata { @@ -1255,7 +1303,7 @@ pub fn extract_content_queries<'a>( first: false, predicate, descendants: config.descendants, - emit_distinct_changes_only: true, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1271,15 +1319,20 @@ pub fn extract_content_queries<'a>( if let Some(decorator) = find_decorator_by_name(&method.decorators, "ContentChild") { if let Some(property_name) = get_property_key_name(&method.key) { - let config = - parse_query_config(allocator, decorator, "ContentChild", source_text); + let config = parse_query_config( + allocator, + decorator, + "ContentChild", + source_text, + consts, + ); if let Some(predicate) = config.predicate { content_child_queries.push(R3QueryMetadata { property_name, first: true, predicate, descendants: config.descendants, - emit_distinct_changes_only: true, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1295,6 +1348,7 @@ pub fn extract_content_queries<'a>( decorator, "ContentChildren", source_text, + consts, ); if let Some(predicate) = config.predicate { content_children_queries.push(R3QueryMetadata { @@ -1302,7 +1356,7 @@ pub fn extract_content_queries<'a>( first: false, predicate, descendants: config.descendants, - emit_distinct_changes_only: true, + emit_distinct_changes_only: config.emit_distinct_changes_only, read: config.read, is_static: config.is_static, is_signal: false, @@ -1500,6 +1554,258 @@ fn parse_host_listener_config<'a>( // Tests // ============================================================================ +/// A class's view and content queries as they're compiled: member decorators +/// and signal queries (predicates referencing same-file consts resolved), then +/// `queries:` from its `@Component` / `@Directive` metadata, in ngtsc's order. +pub fn extract_class_queries<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: &super::StringConsts<'a>, +) -> (Vec<'a, R3QueryMetadata<'a>>, Vec<'a, R3QueryMetadata<'a>>) { + let mut view = extract_view_queries_in(allocator, class, source_text, Some(consts)); + let mut content = extract_content_queries_in(allocator, class, source_text, Some(consts)); + if let Some((Some(config), name)) = super::angular_decorator_config(class) { + let queries = parse_decorator_queries(allocator, config, class, source_text, consts, name); + view.extend(queries.view); + content.extend(queries.content); + } + (view, content) +} + +// ============================================================================ +// `queries:` in @Directive / @Component metadata +// ============================================================================ + +/// Queries declared in decorator metadata, e.g. +/// `@Directive({ queries: { el: new ViewChild('el') } })`. +pub(crate) struct DecoratorQueries<'a> { + pub view: Vec<'a, R3QueryMetadata<'a>>, + pub content: Vec<'a, R3QueryMetadata<'a>>, + /// The first error ngtsc reports for them. + pub error: Option, +} + +const QUERY_TYPES: &[&str] = &["ViewChild", "ViewChildren", "ContentChild", "ContentChildren"]; + +/// ngtsc's `unwrapExpression`: parentheses and `as` casts. +fn unwrap_expression<'a>(mut expr: &'a Expression<'a>) -> &'a Expression<'a> { + loop { + expr = match expr { + Expression::ParenthesizedExpression(e) => &e.expression, + Expression::TSAsExpression(e) => &e.expression, + _ => return expr, + }; + } +} + +/// ngtsc's `reflectObjectLiteral`: property assignments with a static name and +/// shorthand properties; anything else is skipped. +fn reflect_object_literal<'a>( + obj: &'a oxc_ast::ast::ObjectExpression<'a>, +) -> std::vec::Vec<(String, &'a Expression<'a>)> { + let mut entries: std::vec::Vec<(String, &'a Expression<'a>)> = std::vec::Vec::new(); + for prop in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { continue }; + if prop.method || prop.computed || !matches!(prop.kind, oxc_ast::ast::PropertyKind::Init) { + continue; + } + let name = match &prop.key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + PropertyKey::NumericLiteral(n) => n.value.to_string(), + _ => continue, + }; + // A Map keeps the first key's position and the last value. + match entries.iter_mut().find(|(k, _)| *k == name) { + Some(entry) => entry.1 = &prop.value, + None => entries.push((name, &prop.value)), + } + } + entries +} + +/// Parse `queries:` from a decorator metadata object. +/// +/// Reference: `extractQueriesFromDecorator` / `extractDecoratorQueryMetadata` in +/// packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +pub(crate) fn parse_decorator_queries<'a>( + allocator: &'a Allocator, + config: &'a oxc_ast::ast::ObjectExpression<'a>, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: &super::StringConsts<'a>, + decorator_name: &str, +) -> DecoratorQueries<'a> { + let mut queries = DecoratorQueries { + view: Vec::new_in(&allocator), + content: Vec::new_in(&allocator), + error: None, + }; + let Some(query_data) = super::decorator::config_property(config, "queries", consts) else { + return queries; + }; + let Expression::ObjectExpression(query_data) = query_data else { + queries.error = Some("Decorator queries metadata must be an object literal".into()); + return queries; + }; + let evaluator = super::evaluator::Evaluator::new(consts); + for (property_name, expr) in reflect_object_literal(query_data) { + let not_a_query = + || Some("Decorator query metadata must be an instance of a query type".into()); + let Expression::NewExpression(new_expr) = unwrap_expression(expr) else { + queries.error = not_a_query(); + return queries; + }; + // `new ViewChild(...)` / `new core.ViewChild(...)`, imported from @angular/core. + let type_name = match &new_expr.callee { + Expression::Identifier(id) => consts + .scope() + .import(id.name.as_str()) + .filter(|i| i.module == "@angular/core") + .and_then(|i| i.imported), + Expression::StaticMemberExpression(m) => match &m.object { + Expression::Identifier(ns) => consts + .scope() + .import(ns.name.as_str()) + .filter(|i| i.module == "@angular/core" && i.imported.is_none()) + .map(|_| m.property.name.as_str()), + _ => None, + }, + _ => None, + }; + let Some(type_name) = type_name.filter(|t| QUERY_TYPES.contains(t)) else { + queries.error = not_a_query(); + return queries; + }; + match decorator_query( + allocator, + &evaluator, + type_name, + &new_expr.arguments, + property_name, + source_text, + ) { + Ok(query) if type_name.starts_with("Content") => queries.content.push(query), + Ok(query) => queries.view.push(query), + Err(error) => { + queries.error = Some(error); + return queries; + } + } + } + + // A signal query member for the same property is an error. + let signal_queries: std::vec::Vec> = class + .body + .body + .iter() + .filter_map(|element| { + let ClassElement::PropertyDefinition(prop) = element else { return None }; + let name = get_property_key_name(&prop.key)?; + try_parse_signal_query(allocator, prop.value.as_ref()?, name.clone(), source_text) + .map(|_| name) + }) + .collect(); + if queries + .content + .iter() + .chain(queries.view.iter()) + .any(|q| signal_queries.contains(&q.property_name)) + { + queries.error = Some(format!( + "Query is declared multiple times. \"@{decorator_name}\" declares a query for the same property." + )); + } + queries +} + +/// One `new ViewChild(predicate, options?)` and friends. +fn decorator_query<'a>( + allocator: &'a Allocator, + evaluator: &super::evaluator::Evaluator<'_, 'a>, + name: &str, + args: &'a oxc_allocator::Vec<'a, Argument<'a>>, + property_name: String, + source_text: Option<&'a str>, +) -> Result, String> { + use super::evaluator::Value; + let Some(first) = args.first().and_then(Argument::as_expression) else { + return Err(format!("@{name} must have arguments")); + }; + let node = try_unwrap_forward_ref(first).unwrap_or(first); + let predicate = match evaluator.evaluate(node) { + Value::Reference { .. } | Value::Dynamic | Value::Function(_) => { + let expr = convert_oxc_expression(allocator, node, source_text) + .ok_or_else(|| format!("@{name} predicate cannot be interpreted"))?; + QueryPredicate::Type(expr) + } + Value::String(s) => { + let mut selectors = Vec::new_in(&allocator); + selectors.push(Ident::from(allocator.alloc_str(&s))); + QueryPredicate::Selectors(selectors) + } + Value::Array(items) => { + let mut selectors = Vec::new_in(&allocator); + for (i, item) in items.iter().enumerate() { + let Value::String(s) = item else { + return Err(format!( + "Failed to resolve @{name} predicate at position {i} to a string{}", + item.wrong_type_suffix() + )); + }; + selectors.push(Ident::from(allocator.alloc_str(s))); + } + QueryPredicate::Selectors(selectors) + } + other => { + return Err(format!( + "@{name} predicate cannot be interpreted{}", + other.wrong_type_suffix() + )); + } + }; + + let mut config = QueryConfig::default_for(name); + if args.len() == 2 { + let options = args[1].as_expression().map(unwrap_expression); + let Some(Expression::ObjectExpression(options)) = options else { + return Err(format!("@{name} options must be an object literal")); + }; + for (key, value) in reflect_object_literal(options) { + let flag = |option: &str| match evaluator.evaluate(value) { + Value::Bool(b) => Ok(b), + other => Err(format!( + "@{name} options.{option} must be a boolean{}", + other.wrong_type_suffix() + )), + }; + match key.as_str() { + "read" => config.read = convert_oxc_expression(allocator, value, source_text), + "descendants" => config.descendants = flag("descendants")?, + "emitDistinctChangesOnly" => { + config.emit_distinct_changes_only = flag("emitDistinctChangesOnly")?; + } + "static" => config.is_static = flag("static")?, + _ => {} + } + } + } else if args.len() > 2 { + return Err(format!("@{name} has too many arguments")); + } + + Ok(R3QueryMetadata { + property_name: Ident::from(allocator.alloc_str(&property_name)), + first: name == "ViewChild" || name == "ContentChild", + predicate, + descendants: config.descendants, + emit_distinct_changes_only: config.emit_distinct_changes_only, + read: config.read, + is_static: config.is_static, + is_signal: false, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 1ac650c5b..9311e5656 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -111,9 +111,10 @@ pub use directive::{ DirectiveCompileResult, DirectiveDefinitions, QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, StringConsts, collect_string_consts, compile_directive, - compile_directive_from_metadata, extract_content_queries, extract_directive_metadata, - extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, - extract_view_queries, find_directive_decorator_span, generate_directive_definitions, + compile_directive_from_metadata, extract_class_queries, extract_content_queries, + extract_directive_metadata, extract_host_bindings, extract_host_listeners, + extract_input_metadata, extract_output_metadata, extract_view_queries, + find_directive_decorator_span, generate_directive_definitions, }; // Re-export injectable types diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 9708a405e..d07986699 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -230,7 +230,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 98, "fixtures compared"); + assert_eq!(compared, 134, "fixtures compared"); } /// ngtsc emits the method's bare name for `transform: Utils.coerce` (a static diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index a49590e95..dc1754b6a 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -25,6 +25,36 @@ }, "dtsImports": [] }, + { + "name": "ngtsc_spec: should throw error if @Directive.queries field has wrong type", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive} from '@angular/core';\n\n @Directive({\n selector: 'test-dir',\n // @ts-ignore\n queries: 'invalid-queries-type'\n })\n export class TestDir {}\n " + }, + "diagnostics": ["Decorator queries metadata must be an object literal"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should throw error if @Directive.queries object has incorrect values", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive} from '@angular/core';\n\n @Directive({\n selector: 'test-dir',\n queries: {\n myViewQuery: 'invalid-query-type'\n }\n })\n export class TestDir {}\n " + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should throw error if @Directive.queries object has incorrect values (refs to other decorators)", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({\n selector: 'test-dir',\n queries: {\n myViewQuery: new Input()\n }\n })\n export class TestDir {}\n " + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, { "name": "ngtsc_spec: should produce a diagnostic if the transform value is not a function", "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", @@ -181,6 +211,50 @@ }, "dtsImports": [] }, + { + "name": "ngtsc_spec: should generate queries for components", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Component, ContentChild, ContentChildren, TemplateRef, ViewChild} from '@angular/core';\n\n @Component({\n selector: 'test',\n template: '
',\n queries: {\n 'mview': new ViewChild('test1'),\n 'mcontent': new ContentChild('test2'),\n }\n })\n class FooCmp {\n @ContentChild('bar', {read: TemplateRef}) child: any;\n @ContentChildren(TemplateRef) children: any;\n get aview(): any { return null; }\n @ViewChild('accessor') set aview(value: any) {}\n }\n " + }, + "diagnostics": [], + "classes": { + "FooCmp": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"bar\"],5,TemplateRef)(dirIndex,TemplateRef,4)(dirIndex,[\"test2\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.child=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.children=_t);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mcontent=_t.first);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"accessor\"],5)([\"test1\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.aview=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mview=_t.first);}}" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should generate queries for directives", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, ContentChild, ContentChildren, TemplateRef, ViewChild} from '@angular/core';\n import * as core from '@angular/core';\n\n @Directive({\n selector: '[test]',\n queries: {\n 'mview': new ViewChild('test1'),\n 'mcontent': new core.ContentChild('test2'),\n }\n })\n class FooCmp {\n @ContentChild('bar', {read: TemplateRef}) child: any;\n @ContentChildren(TemplateRef) children: any;\n get aview(): any { return null; }\n @ViewChild('accessor') set aview(value: any) {}\n }\n " + }, + "diagnostics": [], + "classes": { + "FooCmp": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"bar\"],5,TemplateRef)(dirIndex,TemplateRef,4)(dirIndex,[\"test2\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.child=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.children=_t);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mcontent=_t.first);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"accessor\"],5)([\"test1\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.aview=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mview=_t.first);}}" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should handle queries that use forwardRef", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Component, ContentChild, TemplateRef, ViewContainerRef, forwardRef} from '@angular/core';\n\n @Component({\n selector: 'test',\n template: '
',\n })\n class FooCmp {\n @ContentChild(forwardRef(() => TemplateRef)) child: any;\n\n @ContentChild(forwardRef(function() { return ViewContainerRef; })) child2: any;\n\n @ContentChild((forwardRef((function() { return 'parens'; }) as any))) childInParens: any;\n }\n " + }, + "diagnostics": [], + "classes": { + "FooCmp": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,TemplateRef,5)(dirIndex,ViewContainerRef,5)(dirIndex,[\"parens\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.child=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.child2=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.childInParens=_t.first);}}" + } + }, + "dtsImports": [] + }, { "name": "ngtsc_spec: should handle queries that use an InjectionToken", "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", @@ -331,6 +405,40 @@ "classes": {}, "dtsImports": [] }, + { + "name": "authoring_queries_spec: should report an error when declared in @Directive metadata", + "origin": "packages/compiler-cli/test/ngtsc/authoring_queries_spec.ts", + "files": { + "test.ts": "\n import {Directive, ViewChild, viewChild} from '@angular/core';\n\n @Directive({\n selector: 'test',\n queries: {\n el: new ViewChild('myLocator'),\n },\n })\n export class TestDir {\n el = viewChild('myLocator');\n }\n " + }, + "diagnostics": [ + "Query is declared multiple times. \"@Directive\" declares a query for the same property." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "authoring_queries_spec: should report an error when declared in @Component metadata", + "origin": "packages/compiler-cli/test/ngtsc/authoring_queries_spec.ts", + "files": { + "test.ts": "\n import {Component, ViewChild, viewChild} from '@angular/core';\n\n @Component({\n selector: 'test',\n template: '',\n queries: {\n el: new ViewChild('myLocator'),\n },\n })\n export class TestComp {\n el = viewChild('myLocator');\n }\n " + }, + "diagnostics": [ + "Query is declared multiple times. \"@Component\" declares a query for the same property." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "authoring_queries_spec: should report an error when a signal-based query function is used in metadata", + "origin": "packages/compiler-cli/test/ngtsc/authoring_queries_spec.ts", + "files": { + "test.ts": "\n import {Component, viewChild} from '@angular/core';\n\n @Component({\n selector: 'test',\n template: '',\n queries: {\n // @ts-ignore\n el: new viewChild('myLocator'),\n },\n })\n export class TestComp {}\n " + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, { "name": "compliance/r3_view_compiler_bindings/order_bindings", "origin": "packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/order_bindings.ts", @@ -979,6 +1087,354 @@ "classes": {}, "dtsImports": [] }, + { + "name": "probe: queries-qBasic", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\"), b: new ViewChildren(TemplateRef, {read: ElementRef}), c: new ContentChild(\"y\", {descendants: false, static: true}), d: new ContentChildren(TemplateRef, {descendants: true, emitDistinctChangesOnly: false})}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"y\"],6)(dirIndex,TemplateRef,1);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.c=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.d=_t);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\"],5)(TemplateRef,5,ElementRef);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.b=_t);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qOrderWithFields", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {mc: new ContentChild(\"m\"), mv: new ViewChild(\"mv\")}})\nexport class Dir { @ContentChild(\"f\") fc: any; @ViewChild(\"fv\") fv: any; @ContentChildren(\"g\") gc: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"f\"],5)(dirIndex,[\"g\"],4)(dirIndex,[\"m\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.fc=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.gc=_t);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mc=_t.first);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"fv\"],5)([\"mv\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.fv=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.mv=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qNs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new core.ViewChild(\"x\")}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qStrArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChildren([\"x\", \"y\"])}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\",\"y\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qCommaStr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChildren(\"x, y\")}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\",\"y\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qConstStr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst S = \"sel\";\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(S)}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"sel\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qForwardRef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class T {}\n@Directive({selector: \"[d]\", queries: {a: new ContentChild(forwardRef(() => T))}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,T,5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qViewDescFalse", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", {descendants: false})}})\nexport class Dir { }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\"],4);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-memberViewDescFalse", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @ViewChild(\"x\", {descendants: false}) a: any; @ViewChildren(\"y\", {emitDistinctChangesOnly: false}) b: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\"],4)([\"y\"],1);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.b=_t);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-memberConstSel", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst S = \"sel\";\n@Directive({selector: \"[d]\"})\nexport class Dir { @ViewChild(S) a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"sel\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.a=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: queries-qNotObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: \"bad\"})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator queries metadata must be an object literal"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qStringValue", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: \"bad\"}})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qInput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new Input()}})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qSignalFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new (viewChild as any)(\"x\")}})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qSignalFnBare", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new viewChild(\"x\")}})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qCallNotNew", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: viewChild(\"x\")}})\nexport class Dir { }\n" + }, + "skip": "ngtsc also reports its separate check for signal query functions called outside a member initializer, which oxc doesn't implement" + }, + { + "name": "probe: queries-qNoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild()}})\nexport class Dir { }\n" + }, + "diagnostics": ["@ViewChild must have arguments"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qTooMany", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", {}, 1)}})\nexport class Dir { }\n" + }, + "diagnostics": ["@ViewChild has too many arguments"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qOptsNotObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", 1)}})\nexport class Dir { }\n" + }, + "diagnostics": [ + "@ViewChild options must be an object literal. Find more at https://next.angular.dev/errors/NG1001" + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qDescNotBool", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", {descendants: 1})}})\nexport class Dir { }\n" + }, + "diagnostics": [ + "@ViewChild options.descendants must be a boolean Value is of type 'number'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qStaticNotBool", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", {static: \"y\"})}})\nexport class Dir { }\n" + }, + "diagnostics": ["@ViewChild options.static must be a boolean Value is of type 'string'."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qEdcoNotBool", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(\"x\", {emitDistinctChangesOnly: 1})}})\nexport class Dir { }\n" + }, + "diagnostics": [ + "@ViewChild options.emitDistinctChangesOnly must be a boolean Value is of type 'number'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qPredNum", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChild(1)}})\nexport class Dir { }\n" + }, + "diagnostics": ["@ViewChild predicate cannot be interpreted Value is of type 'number'."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qPredArrMixed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {a: new ViewChildren([\"x\", 1])}})\nexport class Dir { }\n" + }, + "diagnostics": [ + "Failed to resolve @ViewChildren predicate at position 1 to a string Value is of type 'number'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qSignalCollision", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {el: new ViewChild(\"x\")}})\nexport class Dir { el = viewChild(\"x\"); }\n" + }, + "diagnostics": [ + "Query is declared multiple times. \"@Directive\" declares a query for the same property." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qSignalCollisionContent", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", queries: {el: new ContentChild(\"x\"), v: new ViewChild(\"y\")}})\nexport class Dir { el = core.contentChild(\"x\"); v = viewChild(\"y\"); }\n" + }, + "diagnostics": [ + "Query is declared multiple times. \"@Directive\" declares a query for the same property." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qLocalClass", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nclass ViewChild2 {}\n@Directive({selector: \"[d]\", queries: {a: new ViewChild2(\"x\")}})\nexport class Dir { }\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: queries-qComponent", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, ViewChild, ViewChildren, ContentChild, ContentChildren, TemplateRef, ElementRef, forwardRef, viewChild} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Component({selector: \"c\", template: \"
\", queries: {v: new ViewChild(\"x\"), c: new ContentChild(\"y\")}})\nexport class Cmp {}\n" + }, + "diagnostics": [], + "classes": { + "Cmp": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"y\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.c=_t.first);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"x\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.v=_t.first);}}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, { "name": "probe: resolve-fnMultiStmt", "origin": "probe", diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index e4ef593ed..e632c6f85 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1553,8 +1553,8 @@ pub fn extract_component_metadata_sync( use oxc_angular_compiler::{ ChangeDetectionStrategy as RustChangeDetection, QueryPredicate, ViewEncapsulation as RustViewEncapsulation, build_import_map, collect_string_consts, - extract_component_metadata, extract_content_queries, extract_input_metadata, - extract_output_metadata, extract_view_queries, + extract_class_queries, extract_component_metadata, extract_input_metadata, + extract_output_metadata, }; use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement}; use oxc_parser::Parser; @@ -1720,8 +1720,10 @@ pub fn extract_component_metadata_sync( } } - // Extract view queries from @ViewChild/@ViewChildren decorators - let rust_view_queries = extract_view_queries(&allocator, class, Some(&source)); + // View and content queries exactly as the component is compiled + // (member queries, then `queries:` metadata). + let (rust_view_queries, rust_content_queries) = + extract_class_queries(&allocator, class, Some(&source), &string_consts); let view_queries: Option> = if rust_view_queries.is_empty() { None @@ -1741,9 +1743,6 @@ pub fn extract_component_metadata_sync( ) }; - // Extract content queries from @ContentChild/@ContentChildren decorators - let rust_content_queries = - extract_content_queries(&allocator, class, Some(&source)); let queries: Option> = if rust_content_queries.is_empty() { None diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts new file mode 100644 index 000000000..576ec4e84 --- /dev/null +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { extractComponentMetadataSync } from '../index.js' + +// The queries reported must be the ones the component is compiled with. +describe('extractComponentMetadataSync queries', () => { + it('resolves const selectors and includes `queries:` metadata after member queries', () => { + const [component] = extractComponentMetadataSync( + ` +import { Component, ContentChild, ViewChild } from '@angular/core'; + +const SEL = 'ref'; + +@Component({ + selector: 'app-x', + template: '
', + queries: { + fromMetaView: new ViewChild('metaRef'), + fromMetaContent: new ContentChild('metaContent', { descendants: false }), + }, +}) +export class X { + @ViewChild(SEL) member: any; + @ContentChild('memberContent') memberContent: any; +} +`, + 'x.component.ts', + ) + + expect(component.viewQueries?.map((q) => [q.propertyName, q.predicate])).toEqual([ + ['member', '["ref"]'], + ['fromMetaView', '["metaRef"]'], + ]) + expect(component.queries?.map((q) => [q.propertyName, q.predicate, q.descendants])).toEqual([ + ['memberContent', '["memberContent"]', true], + ['fromMetaContent', '["metaContent"]', false], + ]) + }) +})