diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index 530e8771b..233563a27 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -5,12 +5,16 @@ use oxc_allocator::{Allocator, Box, Vec as AllocVec}; use oxc_ast::ast::{ - Class, ClassElement, Decorator, Expression, FormalParameter, MethodDefinitionKind, PropertyKey, - TSType, TSTypeName, + Class, ClassElement, Decorator, Expression, FormalParameter, MethodDefinitionKind, + ObjectPropertyKind, PropertyKey, TSType, TSTypeName, }; use oxc_str::Ident; use crate::component::{ImportMap, NamespaceRegistry, R3DependencyMetadata}; +use crate::directive::{ + R3InputMetadata, StringConsts, resolve_template_literal, try_parse_signal_input, + try_parse_signal_model, try_parse_signal_output, unwrap_initializer_api_expr, +}; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr, @@ -38,6 +42,7 @@ pub fn build_decorator_metadata_array<'a>( source_text: Option<&'a str>, inlined_template: Option<&'a str>, inlined_styles: Option<&[Ident<'a>]>, + consts: Option<&StringConsts<'a>>, ) -> OutputExpression<'a> { let mut decorator_entries = AllocVec::new_in(allocator); @@ -107,6 +112,18 @@ pub fn build_decorator_metadata_array<'a>( inlined_template, inlined_styles, ); + // Drop config fields whose value is a template literal with an + // unresolvable `${…}` interpolation, matching the AOT `ɵcmp` path + // (which drops e.g. an unresolved `selector`). Otherwise the raw + // template literal would leak verbatim into `setClassMetadata`. + if let Some(consts) = consts { + drop_unresolvable_template_literal_fields( + allocator, + &mut converted, + expr, + consts, + ); + } } args.push(converted); } @@ -238,6 +255,47 @@ fn inline_component_resources<'a>( } } +/// Remove config fields from a converted `@Component` args map when the source +/// value is a template literal whose `${…}` interpolation can't be statically +/// resolved against `consts` (e.g. `selector: \`${UNRESOLVED}-tag\``). +/// +/// Angular's partial evaluator (and OXC's AOT `ɵcmp` extraction) drops such +/// fields rather than emitting a half-evaluated literal. Resolvable template +/// literals are left untouched (converted as-is); only the unresolvable ones are +/// dropped, so the raw `${…}` text never leaks into `setClassMetadata`. +fn drop_unresolvable_template_literal_fields<'a>( + allocator: &'a Allocator, + converted: &mut OutputExpression<'a>, + source: &Expression<'a>, + consts: &StringConsts<'a>, +) { + let Expression::ObjectExpression(obj) = source else { + return; + }; + let OutputExpression::LiteralMap(map) = converted else { + return; + }; + + for property in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = property else { + continue; + }; + let Expression::TemplateLiteral(tpl) = &prop.value else { + continue; + }; + // An empty-interpolation template literal is a plain string — keep it. + if tpl.expressions.is_empty() { + continue; + } + if resolve_template_literal(allocator, tpl, consts).is_some() { + continue; // Resolvable — leave the converted value as-is. + } + if let Some(key) = get_property_key_name(&prop.key) { + map.entries.retain(|entry| entry.is_spread || entry.key != key); + } + } +} + /// Build a `template: "…"` map entry from the inlined content. fn build_template_entry<'a>(allocator: &'a Allocator, content: &'a str) -> LiteralMapEntry<'a> { LiteralMapEntry::new( @@ -309,6 +367,7 @@ pub fn build_ctor_params_metadata<'a>( source_text, None, None, + None, ); map_entries.push(LiteralMapEntry::new( Ident::from("decorators"), @@ -352,6 +411,7 @@ pub fn build_prop_decorators_metadata<'a>( allocator: &'a Allocator, class: &Class<'a>, source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, ) -> Option> { const ANGULAR_PROP_DECORATORS: &[&str] = &[ "Input", @@ -367,15 +427,15 @@ pub fn build_prop_decorators_metadata<'a>( let mut prop_entries = AllocVec::new_in(allocator); for element in &class.body.body { - let (decorators, property_name) = match element { + let (decorators, property_name, value) = match element { ClassElement::PropertyDefinition(prop) => { - (&prop.decorators, get_property_key_name(&prop.key)) + (&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref()) } ClassElement::MethodDefinition(method) => { - (&method.decorators, get_property_key_name(&method.key)) + (&method.decorators, get_property_key_name(&method.key), None) } ClassElement::AccessorProperty(prop) => { - (&prop.decorators, get_property_key_name(&prop.key)) + (&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref()) } _ => continue, }; @@ -393,15 +453,37 @@ pub fn build_prop_decorators_metadata<'a>( }) .collect(); - if angular_decorators.is_empty() { + if !angular_decorators.is_empty() { + // Build decorators array from the real decorators present in source. + let decorators_array = build_decorator_metadata_array( + allocator, + &angular_decorators, + source_text, + None, + None, + None, + ); + prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); continue; } - // Build decorators array for this property - let decorators_array = - build_decorator_metadata_array(allocator, &angular_decorators, source_text, None, None); - - prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); + // No real Angular prop decorator. Synthesize one for initializer-API members + // (`input()`/`output()`/`model()`/`viewChild()`/…) so JIT recompilation + // (`TestBed.overrideComponent`) can reflect them — signal members live only in + // the AOT `ɵcmp`, which the JIT recompile discards. Mirrors Angular's + // compiler-cli `initializer_api_transforms` (applied by the Angular CLI in test + // builds); without it, `setInput`/router-binding fail with NG0315/NG0303/NG0950. + if let Some(value) = value + && let Some(decorators_array) = build_initializer_api_prop_decorators( + allocator, + value, + &prop_name, + source_text, + namespace_registry, + ) + { + prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); + } } if prop_entries.is_empty() { @@ -414,6 +496,244 @@ pub fn build_prop_decorators_metadata<'a>( ))) } +/// Build the synthetic prop-decorator array for a field initialized with an +/// Angular initializer API (`input()`, `output()`, `model()`, or a signal query). +/// Returns `None` when the initializer is not a recognized initializer API. +fn build_initializer_api_prop_decorators<'a>( + allocator: &'a Allocator, + value: &Expression<'a>, + property_name: &Ident<'a>, + source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> Option> { + let mut decorators = AllocVec::new_in(allocator); + + if let Some(input) = try_parse_signal_input(allocator, value, property_name.clone()) { + // input() / input.required() → `Input({ isSignal, alias, required })` + decorators.push(build_signal_input_decorator(allocator, namespace_registry, &input)); + } else if let Some(model) = try_parse_signal_model(allocator, value, property_name.clone()) { + // model() → `Input({ isSignal, alias, required })` + `Output("Change")` + decorators.push(build_signal_input_decorator(allocator, namespace_registry, &model.input)); + decorators.push(build_core_decorator_with_string_arg( + allocator, + namespace_registry, + "Output", + model.output.1.clone(), + )); + } else if let Some((_, binding)) = try_parse_signal_output(value, property_name.clone()) { + // output() / outputFromObservable() → `Output("")` + decorators.push(build_core_decorator_with_string_arg( + allocator, + namespace_registry, + "Output", + binding, + )); + } else if let Some(query) = + build_signal_query_decorator(allocator, value, source_text, namespace_registry) + { + decorators.push(query); + } + + if decorators.is_empty() { + return None; + } + + Some(OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: decorators, source_span: None }, + allocator, + ))) +} + +/// Build `{ type: i0.Input, args: [{ isSignal: true, alias, required }] }`. +/// +/// Matches the `setClassMetadata` shape emitted by `@angular/compiler-cli` (verified against +/// ngc's output) for both `input()`/`input.required()` and `model()`'s input: a three-field +/// config with no `transform` key (signal inputs handle transforms via the input signal at +/// runtime, so the decorator carries no transform). +fn build_signal_input_decorator<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + input: &R3InputMetadata<'a>, +) -> OutputExpression<'a> { + let mut config = AllocVec::new_in(allocator); + config.push(LiteralMapEntry::new( + Ident::from("isSignal"), + bool_literal(allocator, true), + false, + )); + config.push(LiteralMapEntry::new( + Ident::from("alias"), + string_literal(allocator, input.binding_property_name.clone()), + false, + )); + config.push(LiteralMapEntry::new( + Ident::from("required"), + bool_literal(allocator, input.required), + false, + )); + + let mut args = AllocVec::new_in(allocator); + args.push(OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries: config, source_span: None }, + allocator, + ))); + build_core_decorator(allocator, namespace_registry, "Input", args) +} + +/// Build a query decorator from a signal-query initializer +/// (`viewChild`/`viewChildren`/`contentChild`/`contentChildren`), reusing the source +/// positional arguments: `Decorator(, { ..., isSignal: true })`. +/// Mirrors Angular's `queryFunctionsTransforms`. +fn build_signal_query_decorator<'a>( + allocator: &'a Allocator, + value: &Expression<'a>, + source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> Option> { + let Expression::CallExpression(call) = unwrap_initializer_api_expr(value) else { + return None; + }; + let decorator_name = signal_query_decorator_name(&call.callee)?; + + // Predicate: the first positional argument (required), reused as-is. A query with + // no locator is invalid (ngc errors); skip synthesis rather than emit a malformed + // decorator. + let predicate = + convert_oxc_expression(allocator, call.arguments.first()?.to_expression(), source_text)?; + let mut args = AllocVec::new_in(allocator); + args.push(predicate); + + // Options: `{ ..., isSignal: true }`. Spread the second positional + // argument verbatim (matching Angular's `factory.createSpreadAssignment(callArgs[1])`), + // which preserves any options expression, object literal or not. + let mut options = AllocVec::new_in(allocator); + if let Some(second) = call.arguments.get(1) + && let Some(source_options) = + convert_oxc_expression(allocator, second.to_expression(), source_text) + { + options.push(LiteralMapEntry::spread(source_options)); + } + options.push(LiteralMapEntry::new( + Ident::from("isSignal"), + bool_literal(allocator, true), + false, + )); + args.push(OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries: options, source_span: None }, + allocator, + ))); + + Some(build_core_decorator(allocator, namespace_registry, decorator_name, args)) +} + +/// Map a signal-query initializer callee to its decorator name, handling the direct +/// (`viewChild()`), required (`viewChild.required()`), and namespaced (`core.viewChild()`) +/// forms. +fn signal_query_decorator_name(callee: &Expression<'_>) -> Option<&'static str> { + fn name_of(function: &str) -> Option<&'static str> { + match function { + "viewChild" => Some("ViewChild"), + "viewChildren" => Some("ViewChildren"), + "contentChild" => Some("ContentChild"), + "contentChildren" => Some("ContentChildren"), + _ => None, + } + } + + match callee { + Expression::Identifier(id) => name_of(id.name.as_str()), + Expression::StaticMemberExpression(member) => { + if member.property.name == "required" { + match &member.object { + Expression::Identifier(id) => name_of(id.name.as_str()), + Expression::StaticMemberExpression(inner) => { + name_of(inner.property.name.as_str()) + } + _ => None, + } + } else { + // Namespaced call: `core.viewChild(...)`. + name_of(member.property.name.as_str()) + } + } + _ => None, + } +} + +/// Build `{ type: i0., args: [""] }` for a decorator taking a single string. +fn build_core_decorator_with_string_arg<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + arg: Ident<'a>, +) -> OutputExpression<'a> { + let mut args = AllocVec::new_in(allocator); + args.push(string_literal(allocator, arg)); + build_core_decorator(allocator, namespace_registry, decorator_name, args) +} + +/// Build a synthetic Angular core decorator metadata object: `{ type: i0., args: [...] }`. +/// The decorator type is referenced through the `@angular/core` namespace import (`i0`), since a +/// component using signal APIs imports `input`/`output`/… rather than the `Input`/`Output`/query +/// decorators themselves. +fn build_core_decorator<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + args: AllocVec<'a, OutputExpression<'a>>, +) -> OutputExpression<'a> { + let core_namespace = namespace_registry.get_or_assign(&Ident::from("@angular/core")); + let type_expr = OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in( + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: core_namespace, source_span: None }, + allocator, + )), + allocator, + ), + name: Ident::from(decorator_name), + optional: false, + source_span: None, + }, + allocator, + )); + + let mut entries = AllocVec::new_in(allocator); + entries.push(LiteralMapEntry::new(Ident::from("type"), type_expr, false)); + if !args.is_empty() { + entries.push(LiteralMapEntry::new( + Ident::from("args"), + OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: args, source_span: None }, + allocator, + )), + false, + )); + } + + OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries, source_span: None }, + allocator, + )) +} + +/// Build a boolean literal output expression. +fn bool_literal<'a>(allocator: &'a Allocator, value: bool) -> OutputExpression<'a> { + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Boolean(value), source_span: None }, + allocator, + )) +} + +/// Build a string literal output expression. +fn string_literal<'a>(allocator: &'a Allocator, value: Ident<'a>) -> OutputExpression<'a> { + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(value), source_span: None }, + allocator, + )) +} + // ============================================================================ // Internal helper functions // ============================================================================ diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 181a211cb..51c21656d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -40,15 +40,16 @@ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, extract_content_queries, extract_directive_metadata, extract_view_queries, - find_directive_decorator_span, generate_directive_definitions, + find_directive_decorator, find_directive_decorator_span, generate_directive_definitions, }; use crate::dts; use crate::injectable::{ - extract_injectable_metadata, find_injectable_decorator_span, + extract_injectable_metadata, find_injectable_decorator, find_injectable_decorator_span, generate_injectable_definition_from_decorator, }; use crate::ng_module::{ - extract_ng_module_metadata, find_ng_module_decorator_span, generate_full_ng_module_definition, + extract_ng_module_metadata, find_ng_module_decorator, find_ng_module_decorator_span, + generate_full_ng_module_definition, }; use crate::output::ast::{ DeclareFunctionStmt, FunctionExpr, OutputExpression, OutputStatement, ReadPropExpr, @@ -59,7 +60,8 @@ use crate::parser::ParseTemplateOptions; use crate::parser::expression::BindingParser; use crate::parser::html::{HtmlParser, remove_whitespaces}; use crate::pipe::{ - extract_pipe_metadata, find_pipe_decorator_span, generate_full_pipe_definition_from_decorator, + extract_pipe_metadata, find_pipe_decorator, find_pipe_decorator_span, + generate_full_pipe_definition_from_decorator, }; use crate::pipeline::compilation::{DeferBlockDepsEmitMode, TemplateCompilationMode}; use crate::pipeline::emit::{ @@ -672,6 +674,65 @@ fn find_last_import_end(program_body: &[Statement<'_>]) -> Option { last_import_end.map(|pos| pos as usize) } +/// Build the `ɵsetClassMetadata(...)` declaration string for a non-`@Component` +/// decorated class (`@Directive`/`@Pipe`/`@Injectable`/`@NgModule`). +/// +/// Mirrors the `@Component` metadata block (without template/style inlining): +/// emits the decorator metadata, `ctorParameters` (reflected from the class, with +/// imported token types namespace-prefixed via the import map), and prop decorators +/// (real `@Input`/`@Output`/query plus synthesized initializer-API). Returns an +/// empty string when metadata emission is disabled. Matches ngc, which emits +/// `setClassMetadata` for all decorated classes (needed for TestBed overrides). +#[allow(clippy::too_many_arguments)] +fn build_set_class_metadata_decls<'a>( + allocator: &'a Allocator, + class: &oxc_ast::ast::Class<'a>, + class_name: &str, + decorator: &oxc_ast::ast::Decorator<'a>, + options: &TransformOptions, + source: &'a str, + string_consts: &crate::directive::StringConsts<'a>, + import_map: &ImportMap<'a>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> String { + if !options.emit_class_metadata || options.advanced_optimizations { + return String::new(); + } + + let type_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: Ident::from(class_name), source_span: None }, + allocator, + )); + let class_metadata = R3ClassMetadata { + r#type: type_expr, + decorators: build_decorator_metadata_array( + allocator, + &[decorator], + Some(source), + None, + None, + Some(string_consts), + ), + ctor_parameters: build_ctor_params_metadata( + allocator, + class, + None, + namespace_registry, + import_map, + Some(source), + ), + prop_decorators: build_prop_decorators_metadata( + allocator, + class, + Some(source), + namespace_registry, + ), + }; + let metadata_expr = compile_class_metadata(allocator, &class_metadata); + let emitter = JsEmitter::new(); + format!("{};", emitter.emit_expression(&metadata_expr)) +} + // ============================================================================ // JIT Compilation Transform // ============================================================================ @@ -2052,6 +2113,7 @@ pub fn transform_angular_file( Some(source), Some(template), Some(metadata.styles.as_slice()), + Some(&string_consts), ), ctor_parameters: build_ctor_params_metadata( allocator, @@ -2065,6 +2127,7 @@ pub fn transform_angular_file( allocator, class, Some(source), + &mut file_namespace_registry, ), }; @@ -2233,13 +2296,33 @@ pub fn transform_angular_file( .dts_declarations .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); + // Emit setClassMetadata for TestBed support (overrideDirective + + // signal members), mirroring the @Component path. + let decls_after_class = find_directive_decorator(&class.decorators) + .map(|decorator| { + build_set_class_metadata_decls( + allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); - class_definitions - .insert(class_name, (property_assignments, String::new(), String::new())); + class_definitions.insert( + class_name, + (property_assignments, String::new(), decls_after_class), + ); } else if let Some(mut pipe_metadata) = extract_pipe_metadata(allocator, class, implicit_standalone, Some(source)) { @@ -2309,6 +2392,23 @@ pub fn transform_angular_file( has_injectable, )); + // Emit setClassMetadata for TestBed support (overridePipe). + let decls_after_class = find_pipe_decorator(&class.decorators) + .map(|decorator| { + build_set_class_metadata_decls( + allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -2316,7 +2416,7 @@ pub fn transform_angular_file( )); class_definitions.insert( class_name, - (property_assignments, String::new(), String::new()), + (property_assignments, String::new(), decls_after_class), ); } } else if let Some(mut ng_module_metadata) = @@ -2400,6 +2500,28 @@ pub fn transform_angular_file( has_injectable, )); + // Emit setClassMetadata for TestBed support (overrideModule), + // appended after the NgModule's external declarations. + if let Some(decorator) = find_ng_module_decorator(&class.decorators) { + let metadata = build_set_class_metadata_decls( + allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ); + if !metadata.is_empty() { + if !external_decls.is_empty() { + external_decls.push('\n'); + } + external_decls.push_str(&metadata); + } + } + // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push(( class_name.clone(), @@ -2459,6 +2581,23 @@ pub fn transform_angular_file( type_argument_count, )); + // Emit setClassMetadata for TestBed support. + let decls_after_class = find_injectable_decorator(&class.decorators) + .map(|decorator| { + build_set_class_metadata_decls( + allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -2466,7 +2605,7 @@ pub fn transform_angular_file( )); class_definitions.insert( class_name, - (property_assignments, String::new(), String::new()), + (property_assignments, String::new(), decls_after_class), ); } } diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index f88e5a0be..6403500a9 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -22,7 +22,9 @@ use crate::output::ast::{OutputAstBuilder, OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; /// Find the @Directive decorator in a list of decorators. -fn find_directive_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_directive_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_directive_call(&call.callee), Expression::Identifier(id) => id.name == "Directive", @@ -533,7 +535,7 @@ fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option /// string consts. Returns `None` if any interpolation can't be statically /// resolved to a string — matching Angular's all-or-nothing partial evaluator /// for static metadata fields. -fn resolve_template_literal<'a>( +pub(crate) fn resolve_template_literal<'a>( allocator: &'a Allocator, tpl: &TemplateLiteral<'a>, consts: &StringConsts<'a>, diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index c91aec637..279208720 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -24,10 +24,11 @@ pub use compiler::{ DirectiveCompileResult, compile_directive, compile_directive_from_metadata, create_inputs_literal, create_outputs_literal, }; -pub(crate) use decorator::extract_string_value; +pub(crate) use decorator::find_directive_decorator; pub use decorator::{ StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, }; +pub(crate) use decorator::{extract_string_value, resolve_template_literal}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, @@ -37,4 +38,8 @@ pub use property_decorators::{ 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, +}; 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 937d090ec..738e185a7 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -202,12 +202,12 @@ fn parse_input_config<'a>( /// Metadata for a model() signal, which creates both an input and an output. /// /// Based on Angular's `ModelMapping` in `model_function.ts`. -struct ModelMapping<'a> { +pub(crate) struct ModelMapping<'a> { /// The input metadata (signal-based). - input: R3InputMetadata<'a>, + pub(crate) input: R3InputMetadata<'a>, /// The output metadata (class property name, binding property name). /// Output binding name is always `inputName + "Change"`. - output: (Ident<'a>, Ident<'a>), + pub(crate) output: (Ident<'a>, Ident<'a>), } /// Try to detect and parse a signal-based model from a property initializer. @@ -223,13 +223,13 @@ struct ModelMapping<'a> { /// ``` /// /// Based on Angular's `model_function.ts` in the compiler-cli. -fn try_parse_signal_model<'a>( +pub(crate) fn try_parse_signal_model<'a>( allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, ) -> Option> { - // Check if the value is a call expression - let call_expr = match value { + // Check if the value is a call expression (unwrapping `as`/parenthesized). + let call_expr = match unwrap_initializer_api_expr(value) { Expression::CallExpression(call) => call, _ => return None, }; @@ -240,11 +240,12 @@ fn try_parse_signal_model<'a>( Expression::Identifier(id) if id.name == "model" => false, // model.required() - member expression call Expression::StaticMemberExpression(member) => { - // Check for model.required + // Check for model.required / core.model.required if member.property.name == "required" { - match &member.object { - Expression::Identifier(id) if id.name == "model" => true, - _ => return None, + if is_initializer_fn_reference(&member.object, "model") { + true + } else { + return None; } } else if member.property.name == "model" { // Handle namespaced calls like `core.model()` @@ -322,11 +323,11 @@ fn try_parse_signal_model<'a>( /// the observable expression is irrelevant for metadata extraction. /// /// Based on Angular's `output_function.ts` in the compiler-cli. -fn try_parse_signal_output<'a>( +pub(crate) fn try_parse_signal_output<'a>( value: &Expression<'a>, property_name: Ident<'a>, ) -> Option<(Ident<'a>, Ident<'a>)> { - let call_expr = match value { + let call_expr = match unwrap_initializer_api_expr(value) { Expression::CallExpression(call) => call, _ => return None, }; @@ -387,13 +388,39 @@ fn try_parse_signal_output<'a>( /// ``` /// /// Based on Angular's `input_function.ts` in the compiler-cli. -fn try_parse_signal_input<'a>( +/// Unwrap `as`/`satisfies`/parenthesized wrappers around an initializer-API call +/// expression, matching ngc's `tryParseInitializerApi` which recurses through +/// `isAsExpression` and `isParenthesizedExpression` (e.g. `x = input(0) as any`, +/// `x = (input(0))`). +pub(crate) fn unwrap_initializer_api_expr<'a, 'b>(expr: &'b Expression<'a>) -> &'b Expression<'a> { + match expr { + Expression::TSAsExpression(e) => unwrap_initializer_api_expr(&e.expression), + Expression::ParenthesizedExpression(e) => unwrap_initializer_api_expr(&e.expression), + _ => expr, + } +} + +/// Returns `true` when `object` is the `input`/`model`/etc. reference for a +/// namespaced call, i.e. either a bare `` identifier or `.` member +/// access. Used to recognize `.required()` and `core..required()`. +fn is_initializer_fn_reference(object: &Expression<'_>, function_name: &str) -> bool { + match object { + Expression::Identifier(id) => id.name == function_name, + Expression::StaticMemberExpression(member) => { + member.property.name == function_name + && matches!(&member.object, Expression::Identifier(_)) + } + _ => false, + } +} + +pub(crate) fn try_parse_signal_input<'a>( _allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, ) -> Option> { - // Check if the value is a call expression - let call_expr = match value { + // Check if the value is a call expression (unwrapping `as`/parenthesized). + let call_expr = match unwrap_initializer_api_expr(value) { Expression::CallExpression(call) => call, _ => return None, }; @@ -404,11 +431,12 @@ fn try_parse_signal_input<'a>( Expression::Identifier(id) if id.name == "input" => false, // input.required() - member expression call Expression::StaticMemberExpression(member) => { - // Check for input.required + // Check for input.required / core.input.required if member.property.name == "required" { - match &member.object { - Expression::Identifier(id) if id.name == "input" => true, - _ => return None, + if is_initializer_fn_reference(&member.object, "input") { + true + } else { + return None; } } else if member.property.name == "input" { // Handle namespaced calls like `core.input()` @@ -870,8 +898,8 @@ fn try_parse_signal_query<'a>( property_name: Ident<'a>, source_text: Option<&'a str>, ) -> Option<(SignalQueryType, R3QueryMetadata<'a>)> { - // Check if the value is a call expression - let call_expr = match value { + // Check if the value is a call expression (unwrapping `as`/parenthesized). + let call_expr = match unwrap_initializer_api_expr(value) { Expression::CallExpression(call) => call, _ => return None, }; diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 0d0ab178e..61ed3cf5f 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -204,6 +204,13 @@ fn convert_deps_to_r3<'a>( result } +/// Find the `@Injectable` decorator node on a class. +pub(crate) fn find_injectable_decorator<'a>( + decorators: &'a [oxc_ast::ast::Decorator<'a>], +) -> Option<&'a oxc_ast::ast::Decorator<'a>> { + decorators.iter().find(|d| is_injectable_decorator(d)) +} + /// Find the span of the `@Injectable` decorator on a class. pub fn find_injectable_decorator_span(class: &Class<'_>) -> Option { for decorator in &class.decorators { diff --git a/crates/oxc_angular_compiler/src/injectable/mod.rs b/crates/oxc_angular_compiler/src/injectable/mod.rs index c1ad3557a..dd710e6b1 100644 --- a/crates/oxc_angular_compiler/src/injectable/mod.rs +++ b/crates/oxc_angular_compiler/src/injectable/mod.rs @@ -17,6 +17,7 @@ mod definition; mod metadata; pub use compiler::{InjectableCompileResult, compile_injectable, compile_injectable_from_metadata}; +pub(crate) use decorator::find_injectable_decorator; pub use decorator::{ DependencyMetadata, InjectableMetadata, ProvidedInValue, UseClassMetadata, UseExistingMetadata, UseFactoryMetadata, extract_injectable_metadata, find_injectable_decorator_span, diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index aae2f05b8..7ede04e94 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -280,7 +280,9 @@ pub fn extract_ng_module_metadata<'a>( } /// Find the @NgModule decorator in a list of decorators. -fn find_ng_module_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_ng_module_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_ng_module_call(&call.callee), Expression::Identifier(id) => id.name == "NgModule", diff --git a/crates/oxc_angular_compiler/src/ng_module/mod.rs b/crates/oxc_angular_compiler/src/ng_module/mod.rs index 8666185bc..8cea9511d 100644 --- a/crates/oxc_angular_compiler/src/ng_module/mod.rs +++ b/crates/oxc_angular_compiler/src/ng_module/mod.rs @@ -17,6 +17,7 @@ mod definition; mod metadata; pub use compiler::{NgModuleCompileResult, compile_ng_module, compile_ng_module_from_metadata}; +pub(crate) use decorator::find_ng_module_decorator; pub use decorator::{NgModuleMetadata, extract_ng_module_metadata, find_ng_module_decorator_span}; pub use definition::{ FullNgModuleDefinition, NgModuleDefinition, emit_full_ng_module_definition, diff --git a/crates/oxc_angular_compiler/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 288533cb1..b1a359043 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -174,7 +174,9 @@ pub fn extract_pipe_metadata<'a>( } /// Find the @Pipe decorator in a list of decorators. -fn find_pipe_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_pipe_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_pipe_call(&call.callee), Expression::Identifier(id) => id.name == "Pipe", diff --git a/crates/oxc_angular_compiler/src/pipe/mod.rs b/crates/oxc_angular_compiler/src/pipe/mod.rs index 82fc05f60..4f91fc7d9 100644 --- a/crates/oxc_angular_compiler/src/pipe/mod.rs +++ b/crates/oxc_angular_compiler/src/pipe/mod.rs @@ -16,6 +16,7 @@ mod definition; mod metadata; pub use compiler::{PipeCompileResult, compile_pipe, compile_pipe_from_metadata}; +pub(crate) use decorator::find_pipe_decorator; pub use decorator::{PipeMetadata, extract_pipe_metadata, find_pipe_decorator_span}; pub use definition::{ FullPipeDefinition, PipeDefinition, generate_full_pipe_definition_from_decorator, diff --git a/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs new file mode 100644 index 000000000..c2f8afc54 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs @@ -0,0 +1,420 @@ +//! Tests for synthesizing prop-decorator metadata for initializer-API members +//! (`input()`, `output()`, `model()`, `viewChild()`/`contentChild()`/…) into +//! `ɵsetClassMetadata`. +//! +//! Angular's AOT `ɵcmp` carries signal members, but `TestBed.overrideComponent` +//! discards `ɵcmp` and recompiles via JIT, which reconstructs inputs/outputs/ +//! queries ONLY from decorator/prop metadata reflected off the class. Angular's +//! own CLI applies a JIT transform (compiler-cli `initializer_api_transforms/`) +//! that adds synthetic `@Input`/`@Output`/query decorators for signal members so +//! JIT can see them; without it, `setInput`/router-binding fail (NG0315/NG0950). +//! These tests assert OXC emits the equivalent synthetic prop decorators. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +/// Compile `source` with class metadata enabled and return the full output. +fn compile(source: &str) -> String { + let allocator = Allocator::default(); + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + result.code +} + +/// The slice of output from `ɵsetClassMetadata` onward (contains the decorators +/// array and the prop-decorators object). Asserting against this avoids matching +/// identifiers in the import statements or template. +fn metadata_region(code: &str) -> String { + let start = code + .find("\u{275}setClassMetadata") + .unwrap_or_else(|| panic!("ɵsetClassMetadata not present:\n{code}")); + code[start..].to_string() +} + +fn component(body: &str, imports: &str) -> String { + format!( + "import {{ Component, {imports} }} from '@angular/core';\n\n\ + @Component({{ selector: 'c', template: 'x', standalone: true }})\n\ + export class C {{\n{body}\n}}\n" + ) +} + +#[test] +fn signal_input_emits_input_prop_decorator_with_is_signal() { + let md = metadata_region(&compile(&component(" readonly value = input(0);", "input"))); + assert!(md.contains("value"), "prop key missing:\n{md}"); + assert!(md.contains("Input"), "synthetic Input decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "isSignal flag missing:\n{md}"); + assert!(md.contains("required:false"), "required flag missing:\n{md}"); + assert!(md.contains("alias:\"value\""), "alias missing:\n{md}"); + // ngc emits a three-field config `{isSignal, alias, required}` for signal inputs — + // NO `transform` key (verified against @angular/compiler-cli output). + assert!(!md.contains("transform"), "signal input must NOT emit a transform key:\n{md}"); +} + +#[test] +fn synthetic_decorator_uses_core_namespace_reference() { + // Angular references the synthetic decorator through the @angular/core namespace + // import (createSyntheticAngularCoreDecoratorAccess → `i0.Input`), not a bare `Input` + // identifier (a signal component imports `input`, not `Input`). + let md = metadata_region(&compile(&component(" readonly value = input(0);", "input"))); + assert!( + md.contains(".Input"), + "expected a namespaced `.Input` reference, not a bare identifier:\n{md}" + ); +} + +#[test] +fn signal_query_with_options_spreads_source_options() { + let md = metadata_region(&compile(&component( + " readonly items = contentChildren('item', { descendants: true });", + "contentChildren", + ))); + assert!(md.contains("ContentChildren"), "ContentChildren decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); + // The source options object is spread verbatim (matching Angular's + // `{ ...callArgs[1], isSignal: true }`). + assert!(md.contains("descendants"), "source query options not preserved:\n{md}"); +} + +#[test] +fn required_signal_input_marks_required() { + let md = metadata_region(&compile(&component( + " readonly value = input.required();", + "input", + ))); + assert!(md.contains("isSignal:true"), "isSignal missing:\n{md}"); + assert!(md.contains("required:true"), "required:true missing:\n{md}"); +} + +#[test] +fn aliased_signal_input_uses_alias() { + let md = metadata_region(&compile(&component( + " readonly value = input(0, { alias: 'publicName' });", + "input", + ))); + assert!(md.contains("alias:\"publicName\""), "alias not applied:\n{md}"); +} + +#[test] +fn signal_output_emits_output_prop_decorator() { + let md = + metadata_region(&compile(&component(" readonly changed = output();", "output"))); + assert!(md.contains("changed"), "prop key missing:\n{md}"); + assert!(md.contains("Output"), "synthetic Output decorator missing:\n{md}"); + // output() lowers to `Output("")` (a single string arg). + assert!(md.contains("\"changed\""), "output binding name missing:\n{md}"); +} + +#[test] +fn signal_model_emits_input_and_output() { + let md = metadata_region(&compile(&component(" readonly open = model(false);", "model"))); + assert!(md.contains("Input"), "model Input decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "model Input isSignal missing:\n{md}"); + assert!(md.contains("Output"), "model Output decorator missing:\n{md}"); + assert!(md.contains("\"openChange\""), "model output binding `openChange` missing:\n{md}"); +} + +#[test] +fn signal_view_query_emits_view_child_with_is_signal() { + let md = metadata_region(&compile(&component( + " readonly ref = viewChild('tpl');", + "viewChild", + ))); + assert!(md.contains("ViewChild"), "synthetic ViewChild decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); +} + +#[test] +fn signal_content_query_emits_content_child_with_is_signal() { + let md = metadata_region(&compile(&component( + " readonly ref = contentChild('tpl');", + "contentChild", + ))); + assert!(md.contains("ContentChild"), "synthetic ContentChild decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); +} + +#[test] +fn classic_input_output_unchanged_and_not_signal() { + let md = metadata_region(&compile(&component( + " @Input() foo = 1;\n @Output() bar = new EventEmitter();", + "Input, Output, EventEmitter", + ))); + assert!(md.contains("foo"), "classic @Input prop key missing:\n{md}"); + assert!(md.contains("bar"), "classic @Output prop key missing:\n{md}"); + assert!(md.contains("type:Input"), "classic Input type missing:\n{md}"); + assert!(!md.contains("isSignal"), "classic decorators must not gain an isSignal flag:\n{md}"); +} + +#[test] +fn no_metadata_when_emit_disabled() { + let allocator = Allocator::default(); + let source = component(" readonly value = input(0);", "input"); + // emit_class_metadata is default-on, so disable it explicitly. + let options = TransformOptions { emit_class_metadata: false, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", &source, Some(&options), None); + assert!( + !result.code.contains("\u{275}setClassMetadata"), + "no setClassMetadata should be emitted when disabled:\n{}", + result.code + ); +} + +// ─── ngc parity: detector edge cases ─────────────────────────────────────────── + +#[test] +fn signal_input_detected_through_as_cast() { + // ngc unwraps `as` expressions when detecting initializer APIs: + // `foo = input(0) as any` is still recognized as a signal input. + let md = metadata_region(&compile(&component(" readonly value = input(0) as any;", "input"))); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "input behind `as` cast not detected:\n{md}" + ); +} + +#[test] +fn signal_input_detected_through_parentheses() { + // ngc unwraps parenthesized initializers: `foo = (input(0))`. + let md = metadata_region(&compile(&component(" readonly value = (input(0));", "input"))); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "parenthesized input not detected:\n{md}" + ); +} + +#[test] +fn namespaced_required_signal_input_detected() { + // ngc handles `core.input.required()` (namespace import + `.required()`). + let allocator = Allocator::default(); + let source = "import * as core from '@angular/core';\n\n\ + @core.Component({ selector: 'c', template: 'x', standalone: true })\n\ + export class C { readonly value = core.input.required(); }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "core.input.required not detected:\n{md}" + ); + assert!(md.contains("required:true"), "required flag missing for core.input.required:\n{md}"); +} + +#[test] +fn directive_emits_set_class_metadata_with_signal_input() { + // ngc emits setClassMetadata for @Directive too (incl. signal-member prop + // decorators), so signal inputs survive TestBed.overrideDirective. + let allocator = Allocator::default(); + let source = "import { Directive, input, Input } from '@angular/core';\n\n\ + @Directive({ selector: '[appFoo]', standalone: true })\n\ + export class FooDirective {\n\ + readonly value = input('x');\n\ + @Input() classic = 0;\n\ + }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + assert!( + result.code.contains("\u{275}setClassMetadata"), + "directive should emit setClassMetadata:\n{}", + result.code + ); + let md = metadata_region(&result.code); + assert!(md.contains("Directive"), "Directive decorator metadata missing:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "signal input prop decorator missing for directive:\n{md}" + ); + assert!(md.contains("classic"), "classic @Input prop decorator missing:\n{md}"); +} + +#[test] +fn pipe_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { Pipe } from '@angular/core';\n\n\ + @Pipe({ name: 'foo', standalone: true })\n\ + export class FooPipe { transform(v: unknown) { return v; } }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.pipe.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Pipe"), "Pipe decorator metadata missing:\n{md}"); + assert!(md.contains("name:\"foo\""), "Pipe name arg missing:\n{md}"); +} + +#[test] +fn injectable_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { Injectable } from '@angular/core';\n\n\ + @Injectable({ providedIn: 'root' })\n\ + export class FooService {}\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.service.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Injectable"), "Injectable decorator metadata missing:\n{md}"); +} + +#[test] +fn injectable_with_ctor_di_emits_namespaced_ctor_parameters() { + // ctorParameters must namespace-prefix imported token types (e.g. i0.Injector), + // matching ngc — verified the import_map path handles this without explicit deps. + let allocator = Allocator::default(); + let source = "import { Injectable, Injector } from '@angular/core';\n\n\ + @Injectable()\n\ + export class FooService { constructor(private inj: Injector) {} }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.service.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains(".Injector"), "ctorParameters should namespace-prefix Injector:\n{md}"); +} + +#[test] +fn ng_module_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { NgModule } from '@angular/core';\n\n\ + @NgModule({ declarations: [], imports: [] })\n\ + export class FooModule {}\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.module.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("NgModule"), "NgModule decorator metadata missing:\n{md}"); +} + +// ─── classic decorator coverage (regression guards) ──────────────────────────── + +/// Full classic decorator set on a component — verified byte-for-byte against ngc. +const CLASSIC_MEMBERS: &str = "\ + @Input() a = 0;\n\ + @Input('aliasB') b = 0;\n\ + @Input({ alias: 'aliasC', required: true }) c = 0;\n\ + @Output() d = new EventEmitter();\n\ + @Output('aliasE') e = new EventEmitter();\n\ + @ViewChild('ref') vc: unknown;\n\ + @ViewChildren('refs') vcs: unknown;\n\ + @ContentChild('cc', { read: ElementRef }) cc: unknown;\n\ + @ContentChildren('ccs', { descendants: true }) ccs: unknown;\n\ + @HostBinding('class.active') active = true;\n\ + @HostListener('click', ['$event']) onClick(_e: unknown) {}"; + +const CLASSIC_IMPORTS: &str = "Input, Output, EventEmitter, ViewChild, ViewChildren, ContentChild, ContentChildren, \ + HostBinding, HostListener, ElementRef"; + +fn assert_classic_decorators(raw: &str) { + // The emitter wraps long setClassMetadata across lines; strip whitespace so the + // assertions match regardless of wrapping (no string literal here contains spaces). + let md: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let md = md.as_str(); + assert!(md.contains("a:[{type:Input}]"), "plain @Input:\n{md}"); + assert!(md.contains("b:[{type:Input,args:[\"aliasB\"]}]"), "aliased @Input:\n{md}"); + assert!( + md.contains("c:[{type:Input,args:[{alias:\"aliasC\",required:true}]}]"), + "@Input config object:\n{md}" + ); + assert!(md.contains("d:[{type:Output}]"), "plain @Output:\n{md}"); + assert!(md.contains("e:[{type:Output,args:[\"aliasE\"]}]"), "aliased @Output:\n{md}"); + assert!(md.contains("vc:[{type:ViewChild,args:[\"ref\"]}]"), "@ViewChild:\n{md}"); + assert!(md.contains("vcs:[{type:ViewChildren,args:[\"refs\"]}]"), "@ViewChildren:\n{md}"); + assert!( + md.contains("cc:[{type:ContentChild,args:[\"cc\",{read:ElementRef}]}]"), + "@ContentChild with read:\n{md}" + ); + assert!( + md.contains("ccs:[{type:ContentChildren,args:[\"ccs\",{descendants:true}]}]"), + "@ContentChildren with descendants:\n{md}" + ); + assert!( + md.contains("active:[{type:HostBinding,args:[\"class.active\"]}]"), + "@HostBinding:\n{md}" + ); + assert!( + md.contains("onClick:[{type:HostListener,args:[\"click\",[\"$event\"]]}]"), + "@HostListener with args:\n{md}" + ); + // Classic decorators reference the bare imported symbol, not `i0.Input`. + assert!(!md.contains("isSignal"), "classic decorators must not carry isSignal:\n{md}"); +} + +#[test] +fn component_classic_decorators_match_ngc() { + let md = metadata_region(&compile(&component(CLASSIC_MEMBERS, CLASSIC_IMPORTS))); + assert_classic_decorators(&md); +} + +#[test] +fn directive_classic_decorators_match_ngc() { + let allocator = Allocator::default(); + let source = format!( + "import {{ Directive, {CLASSIC_IMPORTS} }} from '@angular/core';\n\n\ + @Directive({{ selector: '[appFoo]' }})\n\ + export class FooDirective {{\n{CLASSIC_MEMBERS}\n}}\n" + ); + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", &source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + assert_classic_decorators(&metadata_region(&result.code)); +} + +#[test] +fn mixed_classic_and_signal_members_coexist() { + let raw = metadata_region(&compile(&component( + " @Input() classic = 0;\n readonly sig = input('x');\n readonly sigOut = output();\n readonly vc = viewChild('ref');", + "Input, input, output, viewChild", + ))); + let md: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let md = md.as_str(); + // Classic input emitted bare; signal members synthesized with isSignal. + assert!(md.contains("classic:[{type:Input}]"), "classic @Input:\n{md}"); + assert!(md.contains("sig:[{type:i0.Input,args:[{isSignal:true"), "signal input:\n{md}"); + assert!(md.contains("sigOut:[{type:i0.Output"), "signal output:\n{md}"); + assert!( + md.contains("vc:[{type:i0.ViewChild,args:[\"ref\",{isSignal:true}]}]"), + "signal query:\n{md}" + ); +} + +#[test] +fn directive_no_metadata_when_emit_disabled() { + let allocator = Allocator::default(); + let source = "import { Directive, input } from '@angular/core';\n\n\ + @Directive({ selector: '[appFoo]', standalone: true })\n\ + export class FooDirective { readonly value = input('x'); }\n"; + let options = TransformOptions { emit_class_metadata: false, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", source, Some(&options), None); + assert!( + !result.code.contains("\u{275}setClassMetadata"), + "no setClassMetadata when disabled:\n{}", + result.code + ); +} + +#[test] +fn namespaced_required_signal_model_detected() { + // ngc handles `core.model.required()` → Input(isSignal) + Output. + let allocator = Allocator::default(); + let source = "import * as core from '@angular/core';\n\n\ + @core.Component({ selector: 'c', template: 'x', standalone: true })\n\ + export class C { readonly value = core.model.required(); }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "core.model.required input not detected:\n{md}" + ); + assert!(md.contains("Output"), "core.model.required output not detected:\n{md}"); + assert!(md.contains("required:true"), "required flag missing for core.model.required:\n{md}"); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap index a3532cc06..eee3478e1 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap @@ -18,6 +18,12 @@ static ɵfac = function HostDir_Factory(__ngFactoryType__) { static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:[0,"valueAlias","value"], color:[0,"colorAlias","color"]},outputs:{opened:"openedAlias",closed:"closedAlias"}}); } +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(HostDir, + [{type:Directive,args:[{}]}],null,{value:[{type:Input,args:["valueAlias"]}], + color:[{type:Input,args:["colorAlias"]}],opened:[{type:Output,args:["openedAlias"]}], + closed:[{type:Output,args:["closedAlias"]}]})); +})(); export class MyComponent { diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap index f9eee3784..a44fe3308 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap @@ -18,6 +18,11 @@ static ɵfac = function HostDir_Factory(__ngFactoryType__) { static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:"value",color:"color"}, outputs:{opened:"opened",closed:"closed"}}); } +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(HostDir, + [{type:Directive,args:[{}]}],null,{value:[{type:Input}],color:[{type:Input}], + opened:[{type:Output}],closed:[{type:Output}]})); +})(); export class MyComponent { diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap index 14d726590..a969f13ad 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap @@ -26,5 +26,6 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{clicked:[{type:i0.Output,args:["clicked"]}],queryChanged:[{type:i0.Output, + args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap index 82c0ed1d7..e2a96f1d6 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap @@ -29,5 +29,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{queryChanged:[{type:i0.Output,args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap index 95dd567dd..79d9ea018 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{valueChanged:[{type:i0.Output,args:["valueChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap index c8637a0fa..c40e5080b 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{queryChanged:[{type:i0.Output,args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap index 52fd1b610..850cef7f4 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{_clicked:[{type:i0.Output,args:["clicked"]}]})); })(); diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 35ffbbc06..e45257bab 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1995,6 +1995,7 @@ pub fn compile_class_metadata_sync( Some(&source), None, None, + None, ); // Build constructor parameters metadata @@ -2013,8 +2014,12 @@ pub fn compile_class_metadata_sync( ); // Build property decorators metadata - let prop_decorators_expr = - core_build_prop_decorators_metadata(&allocator, class, Some(&source)); + let prop_decorators_expr = core_build_prop_decorators_metadata( + &allocator, + class, + Some(&source), + &mut namespace_registry, + ); // Create R3ClassMetadata let metadata = R3ClassMetadata {