diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index cf04251d0..4c6b45274 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -20,14 +20,28 @@ use crate::output::oxc_converter::convert_oxc_expression; /// Build the decorators metadata array expression. /// /// Creates: `[{ type: Component, args: [{ selector: '...', ... }] }]` +/// +/// When `inlined_template` and/or `inlined_styles` are provided (typically for +/// `@Component` decorators with `templateUrl`/`styleUrls`/`styleUrl` resolved +/// via `ResolvedResources`), the first argument of the first decorator (the +/// component config object literal) is rewritten so that `templateUrl` becomes +/// `template` (with content inlined) and `styleUrls`/`styleUrl` are folded into +/// the `styles` array. This matches Angular's `transformDecoratorResources` (see +/// `inline_component_resources` below for the source-cited semantics) and is +/// required for TestBed JIT recompilation, since Angular's +/// `componentNeedsResolution(metadata)` check throws when `templateUrl` is set +/// without a sibling `template` field, or when `styleUrls?.length > 0`, even +/// though the AOT-compiled `ɵcmp` already has the template baked in. pub fn build_decorator_metadata_array<'a>( allocator: &'a Allocator, decorators: &[&Decorator<'a>], source_text: Option<&'a str>, + inlined_template: Option<&'a str>, + inlined_styles: Option<&[Ident<'a>]>, ) -> OutputExpression<'a> { let mut decorator_entries = AllocVec::new_in(allocator); - for decorator in decorators { + for (decorator_idx, decorator) in decorators.iter().enumerate() { let mut map_entries = AllocVec::new_in(allocator); // Get decorator type name @@ -71,10 +85,29 @@ pub fn build_decorator_metadata_array<'a>( if let Expression::CallExpression(call) = &decorator.expression && !call.arguments.is_empty() { + // Gate resource inlining on the decorator's name, matching Angular's + // `if (dec.name !== 'Component') return dec;` at the top of + // `transformDecoratorResources`. Without this, other decorators that + // happen to use resource-shaped keys (e.g. `@Inject({ templateUrl: … })`, + // legal TS even if nonsensical) get their literals stripped. + let is_component_decorator = + get_decorator_name(decorator).is_some_and(|n| n == "Component"); + let mut args = AllocVec::new_in(allocator); - for arg in &call.arguments { + for (arg_idx, arg) in call.arguments.iter().enumerate() { let expr = arg.to_expression(); - if let Some(converted) = convert_oxc_expression(allocator, expr, source_text) { + if let Some(mut converted) = convert_oxc_expression(allocator, expr, source_text) { + // Inline resolved templates/styles into the first arg of the + // first @Component decorator. Other decorators / other args + // are left alone. + if is_component_decorator && decorator_idx == 0 && arg_idx == 0 { + inline_component_resources( + allocator, + &mut converted, + inlined_template, + inlined_styles, + ); + } args.push(converted); } } @@ -104,6 +137,119 @@ pub fn build_decorator_metadata_array<'a>( )) } +/// Rewrite the `@Component` config map so external resource references are +/// inlined into the `setClassMetadata` args. +/// +/// Mirrors Angular's `transformDecoratorResources` (in +/// `compiler-cli/src/ngtsc/annotations/component/src/resources.ts`), which +/// operates on a `Map` and uses `Map.delete` / +/// `Map.set` semantics: +/// +/// - **Fast path**: bail out unchanged when the source has none of `templateUrl`, +/// `styleUrls`, `styleUrl`, or `styles` — preserves the original AST for +/// best source-map fidelity. +/// - **`templateUrl` → `template`**: when present, `templateUrl` is deleted and +/// `template` is set to the inlined content. If the source already had a +/// `template` key (illegal but possible), the existing entry is overwritten +/// *in place* with the inlined value (matches `Map.set` on an existing key). +/// Otherwise the new `template` is appended at the end (matches `Map.set` on +/// a fresh key). +/// - **`styleUrls` / `styleUrl` / existing `styles`**: all deleted; the +/// consolidated `styles` array (whitespace-only entries filtered) is appended +/// at the end. `inlined_styles` is the FINAL canonical list — the caller is +/// responsible for merging inline + resolved content (which `resolve_styles` +/// already does into `ComponentMetadata::styles`). +fn inline_component_resources<'a>( + allocator: &'a Allocator, + expr: &mut OutputExpression<'a>, + inlined_template: Option<&'a str>, + inlined_styles: Option<&[Ident<'a>]>, +) { + let OutputExpression::LiteralMap(map_box) = expr else { + return; + }; + + // Fast-path: no resource fields → preserve original AST. + let has_template_url = map_box.entries.iter().any(|e| e.key.as_str() == "templateUrl"); + let has_style_field = map_box + .entries + .iter() + .any(|e| matches!(e.key.as_str(), "styleUrls" | "styleUrl" | "styles")); + if !has_template_url && !has_style_field { + return; + } + + let original_entries = std::mem::replace(&mut map_box.entries, AllocVec::new_in(allocator)); + + // First pass: drop the deleted keys; if both `templateUrl` and `template` + // existed in source, overwrite the existing `template` in place (Map.set + // semantics). + let mut template_emitted = false; + for entry in original_entries { + match entry.key.as_str() { + "templateUrl" | "styleUrls" | "styleUrl" | "styles" => { + // Dropped — replacements (if any) are emitted below. + } + "template" if has_template_url && inlined_template.is_some() => { + // Overwrite-in-place: emit the inlined value at the source + // `template` key's original position. + map_box.entries.push(build_template_entry(allocator, inlined_template.unwrap())); + template_emitted = true; + } + _ => map_box.entries.push(entry), + } + } + + // If `templateUrl` was in source but no source `template` slot received + // the in-place overwrite, append the resolved template at the end — + // matching `Map.set('template', …)` on a key that didn't previously exist. + if has_template_url + && !template_emitted + && let Some(tpl) = inlined_template + { + map_box.entries.push(build_template_entry(allocator, tpl)); + } + + // Styles are *always* appended at the end (we always delete the pre-existing + // `styles`/`styleUrl(s)`, mirroring Angular's unconditional `metadata.delete` + // for all three keys before `metadata.set('styles', …)`). + if let Some(styles) = inlined_styles { + let mut style_entries = AllocVec::new_in(allocator); + for style in styles { + // Match Angular's `style.trim().length > 0` filter. + if style.as_str().trim().is_empty() { + continue; + } + style_entries.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(*style), source_span: None }, + allocator, + ))); + } + if !style_entries.is_empty() { + map_box.entries.push(LiteralMapEntry::new( + Ident::from("styles"), + OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: style_entries, source_span: None }, + allocator, + )), + false, + )); + } + } +} + +/// Build a `template: "…"` map entry from the inlined content. +fn build_template_entry<'a>(allocator: &'a Allocator, content: &'a str) -> LiteralMapEntry<'a> { + LiteralMapEntry::new( + Ident::from("template"), + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(Ident::from(content)), source_span: None }, + allocator, + )), + false, + ) +} + /// Build constructor parameters metadata. /// /// Creates: `() => [{ type: SomeService, decorators: [...] }, ...]` @@ -157,8 +303,13 @@ pub fn build_ctor_params_metadata<'a>( // Extract decorators from the parameter let param_decorators = extract_angular_decorators_from_param(param); if !param_decorators.is_empty() { - let decorators_array = - build_decorator_metadata_array(allocator, ¶m_decorators, source_text); + let decorators_array = build_decorator_metadata_array( + allocator, + ¶m_decorators, + source_text, + None, + None, + ); map_entries.push(LiteralMapEntry::new( Ident::from("decorators"), decorators_array, @@ -248,7 +399,7 @@ pub fn build_prop_decorators_metadata<'a>( // Build decorators array for this property let decorators_array = - build_decorator_metadata_array(allocator, &angular_decorators, source_text); + build_decorator_metadata_array(allocator, &angular_decorators, source_text, None, None); prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); } diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index dc548416b..a3b332935 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -173,10 +173,12 @@ pub struct TransformOptions { /// Emit setClassMetadata() calls for TestBed support. /// - /// When true, generates `ɵɵsetClassMetadata()` calls wrapped in a dev-mode guard. - /// This preserves original decorator information for TestBed's recompilation APIs. + /// When true, generates `ɵɵsetClassMetadata()` calls wrapped in + /// `(typeof ngDevMode === "undefined" || ngDevMode) && …`. Production bundles + /// tree-shake the guarded call. Preserves original decorator information for + /// TestBed's recompilation APIs. /// - /// Default: false (metadata is dev-only and usually stripped in production) + /// Default: true — matches `ngc`, which always emits class metadata. pub emit_class_metadata: bool, /// Minify final component styles before emitting them into `styles: [...]`. @@ -231,8 +233,9 @@ impl Default for TransformOptions { tsconfig_path: None, // Resolved imports for host directives resolved_imports: None, - // Class metadata for TestBed support (disabled by default) - emit_class_metadata: false, + // Class metadata for TestBed support — matches ngc, which always emits + // it; production bundles strip the guarded call via tree-shaking. + emit_class_metadata: true, minify_component_styles: false, } } @@ -2021,6 +2024,8 @@ pub fn transform_angular_file( allocator, &[decorator], Some(source), + Some(template), + Some(metadata.styles.as_slice()), ), ctor_parameters: build_ctor_params_metadata( allocator, @@ -2915,16 +2920,20 @@ fn compile_component_full<'a>( } /// Resolve template content from inline or external source. +/// +/// Precedence matches Angular's AOT compiler (`parseTemplateDeclaration` in +/// `compiler-cli/src/ngtsc/annotations/component/src/resources.ts`): when both +/// `templateUrl` and inline `template` are present, **`templateUrl` wins** and +/// the inline `template` is silently ignored. Angular's reference checks +/// `component.has('templateUrl')` first and returns immediately, so the inline +/// branch is never reached. (ngc's JIT runtime diverges — it prefers inline via +/// `componentNeedsResolution` — but OXC is AOT-equivalent.) fn resolve_template( metadata: &ComponentMetadata<'_>, resources: Option<&ResolvedResources>, ) -> Option { - // Prefer inline template - if let Some(template) = &metadata.template { - return Some(template.to_string()); - } - - // Try to resolve from external resources + // ngc AOT precedence: templateUrl first, falling through to inline only when + // no resolved content is available. if let Some(template_url) = &metadata.template_url { if let Some(resources) = resources { if let Some(template) = resources.templates.get(template_url.as_str()) { @@ -2933,6 +2942,10 @@ fn resolve_template( } } + if let Some(template) = &metadata.template { + return Some(template.to_string()); + } + None } diff --git a/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs b/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs new file mode 100644 index 000000000..980873640 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs @@ -0,0 +1,761 @@ +//! Tests for inlining resolved external resources (`templateUrl` / `styleUrls` / +//! `styleUrl`) into the `setClassMetadata` arguments. +//! +//! Reference behavior is `transformDecoratorResources` in Angular's +//! `compiler-cli/src/ngtsc/annotations/component/src/resources.ts`. See the +//! compliance test at +//! `compiler-cli/test/compliance/test_cases/r3_compiler_compliance/class_metadata/` +//! for the canonical output shape (e.g. `templateUrl: 'test_cmp_template.html'` +//! becomes `template: "Test template\n"`). + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ResolvedResources, TransformOptions, transform_angular_file}; +use std::collections::HashMap; + +/// Extract the `args` payload of the first `ɵsetClassMetadata` call. +/// +/// `setClassMetadata` is emitted as +/// `setClassMetadata(ClassName, [{ type: ..., args: [{...}] }], null, null)`. +/// We slice from the call site through to the close of the decorators array +/// (which always ends with `}]` — close-decorator-object, close-array). Tests +/// then assert against that segment. +fn extract_metadata_args(code: &str) -> String { + let start = code + .find("ɵsetClassMetadata") + .unwrap_or_else(|| panic!("setClassMetadata not present in output:\n{code}")); + let tail = &code[start..]; + // Track paren depth from the opening `(` of `setClassMetadata(`. The end + // of the second positional argument (the decorators array) is the comma + // at paren-depth 1. + let open = tail.find('(').expect("missing `(` after setClassMetadata in output"); + let bytes = tail.as_bytes(); + let mut depth: i32 = 0; + let mut comma_count = 0; + let mut end = tail.len(); + for (i, &b) in bytes.iter().enumerate().skip(open) { + match b { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b',' if depth == 1 => { + comma_count += 1; + if comma_count == 2 { + // First comma separates ClassName from decorators; second + // is the end of the decorators array. + end = i; + break; + } + } + _ => {} + } + } + tail[..end].to_string() +} + +fn run_with_resources(source: &str, resources: ResolvedResources) -> 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), + Some(&resources), + ); + assert!( + !result.has_errors(), + "Compilation should succeed. Diagnostics: {:?}", + result.diagnostics + ); + result.code +} + +/// `templateUrl` should be replaced by `template` with the resolved content. +/// Reference: compliance test `r3_compiler_compliance/class_metadata/class_decorators`. +#[test] +fn templateurl_is_replaced_with_inlined_template_content() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + templateUrl: 'test_cmp_template.html', +}) +export class ComponentWithExternalResource {} +"#; + + let mut templates = HashMap::new(); + templates + .insert("test_cmp_template.html".to_string(), "Test template\n".to_string()); + let resources = ResolvedResources { templates, styles: HashMap::new() }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!(!metadata.contains("templateUrl"), "templateUrl should be removed. Got:\n{metadata}"); + assert!( + metadata.contains(r#"template:"Test template\n""#) + || metadata.contains("template: \"Test template\\n\""), + "Inlined template content should be present. Got:\n{metadata}" + ); +} + +/// When the source has no resource fields at all, the metadata should be left +/// alone — even when `resolved_resources` is supplied. Angular's +/// `transformDecoratorResources` returns the original decorator in this case. +#[test] +fn no_resource_fields_leaves_metadata_untouched() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
inline
', + standalone: true, +}) +export class InlineComponent {} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + metadata.contains(r#"template:"
inline
""#) + || metadata.contains("template: \"
inline
\""), + "Inline template should be preserved verbatim. Got:\n{metadata}" + ); + assert!( + metadata.contains(r#"selector:"test-cmp""#) || metadata.contains("selector: \"test-cmp\""), + "Selector should be preserved. Got:\n{metadata}" + ); +} + +/// `styleUrls` should be replaced by `styles` carrying the resolved content +/// strings. The `styleUrls` key itself must not appear in the output, since +/// Angular's `componentNeedsResolution` check uses `styleUrls?.length`. +#[test] +fn styleurls_is_replaced_with_inlined_styles_array() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrls: ['./a.css', './b.css'], +}) +export class StyledComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./a.css".to_string(), vec!["div { color: red; }".to_string()]); + styles.insert("./b.css".to_string(), vec!["span { color: blue; }".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!(!metadata.contains("styleUrls"), "styleUrls should be removed. Got:\n{metadata}"); + assert!( + metadata.contains(r#""div { color: red; }""#) || metadata.contains("'div { color: red; }'"), + "Resolved style content for a.css should be inlined. Got:\n{metadata}" + ); + assert!( + metadata.contains(r#""span { color: blue; }""#) + || metadata.contains("'span { color: blue; }'"), + "Resolved style content for b.css should be inlined. Got:\n{metadata}" + ); +} + +/// `styleUrl` (singular, Angular 17+) should also be replaced by `styles`. +/// Angular's `componentNeedsResolution` checks `component.styleUrl` too. +#[test] +fn styleurl_singular_is_replaced_with_inlined_styles_array() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrl: './a.css', +}) +export class StyledComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./a.css".to_string(), vec!["div { color: red; }".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + !metadata.contains("styleUrl:") && !metadata.contains("styleUrl "), + "styleUrl should be removed. Got:\n{metadata}" + ); + assert!( + metadata.contains(r#""div { color: red; }""#) || metadata.contains("'div { color: red; }'"), + "Resolved style content should be inlined. Got:\n{metadata}" + ); +} + +/// Whitespace-only style strings should be dropped, matching Angular's +/// `style.trim().length > 0` filter in `transformDecoratorResources`. If every +/// resolved style is empty, the `styles` key should not appear at all. +#[test] +fn empty_styles_are_filtered_out() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrls: ['./empty.css', './also-empty.css'], +}) +export class EmptyStylesComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./empty.css".to_string(), vec![String::new()]); + styles.insert("./also-empty.css".to_string(), vec![" \n ".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!(!metadata.contains("styleUrls"), "styleUrls should be removed. Got:\n{metadata}"); + assert!( + !metadata.contains("styles:"), + "When all resolved styles are empty/whitespace, the styles key should be omitted. Got:\n{metadata}" + ); +} + +/// `componentNeedsResolution` from Angular's runtime fails if any of these +/// three keys remain in the metadata (alongside other conditions). This is the +/// blocker for TestBed integration when the AOT-compiled component is later +/// re-validated against its preserved metadata. Verify they're all gone. +#[test] +fn no_resource_keys_remain_after_inlining() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + templateUrl: './tmpl.html', + styleUrls: ['./a.css'], +}) +export class FullComponent {} +"#; + + let mut templates = HashMap::new(); + templates.insert("./tmpl.html".to_string(), "

".to_string()); + let mut styles = HashMap::new(); + styles.insert("./a.css".to_string(), vec!["p {}".to_string()]); + let resources = ResolvedResources { templates, styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + for forbidden in ["templateUrl", "styleUrls", "styleUrl:"] { + assert!( + !metadata.contains(forbidden), + "After inlining, `{forbidden}` should not appear in setClassMetadata. Got:\n{metadata}" + ); + } +} + +/// Resolved styles must be ADDED to any inline `styles` array already present +/// in the decorator, not replace it. The Angular reference impl reads existing +/// `styles` from the source decorator and includes them alongside the resolved +/// styleUrl content (with the same non-empty filter). +#[test] +fn inline_styles_array_is_merged_with_resolved_styles() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styles: ['.inline { color: green; }'], + styleUrls: ['./external.css'], +}) +export class MergedStylesComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./external.css".to_string(), vec![".external { color: red; }".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + metadata.contains(".inline { color: green; }"), + "Pre-existing inline style should be preserved in merged output. Got:\n{metadata}" + ); + assert!( + metadata.contains(".external { color: red; }"), + "Resolved external style should be inlined in merged output. Got:\n{metadata}" + ); + assert!( + !metadata.contains("styleUrls"), + "styleUrls should be removed even when merging. Got:\n{metadata}" + ); +} + +/// Bail-out: when emit_class_metadata is on, but the component has only inline +/// resources and no resource URLs, the metadata should pass through unchanged. +/// This is the "fast path" in Angular's reference impl (returns the original +/// decorator). +#[test] +fn purely_inline_component_metadata_passes_through() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
hello
', + styles: ['div { color: red; }'], +}) +export class PureInlineComponent {} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + metadata.contains(r#"template:"
hello
""#) + || metadata.contains("template: \"
hello
\""), + "Inline template should be passed through. Got:\n{metadata}" + ); + assert!( + metadata.contains("div { color: red; }"), + "Inline styles array should be preserved. Got:\n{metadata}" + ); +} + +/// Inline `styles:[...]` source content must not be duplicated when the caller +/// also passes those styles in `inlined_styles`. ng-packagr / vite-side resolution +/// produces a merged styles list that already includes the inline source styles, +/// so the AST-side `styles:[...]` entry from the decorator should NOT be re-merged. +/// Without this guarantee, a component with `styles: ['.foo {}']` would end up +/// with `styles: ['.foo {}', '.foo {}']` in the metadata. +#[test] +fn inline_styles_are_not_duplicated_when_also_in_inlined_styles() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
hello
', + styles: ['.foo { color: red; }'], +}) +export class InlineStylesComponent {} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + let occurrences = metadata.matches(".foo { color: red; }").count(); + assert_eq!( + occurrences, 1, + "Inline style should appear exactly once in setClassMetadata, not duplicated. Got {occurrences} occurrences in:\n{metadata}" + ); +} + +// ================================================================= +// Bug 1 (duplicate styles) — additional coverage variants +// ================================================================= + +/// Multiple distinct inline styles must each appear exactly once, in source +/// order. Catches a regression where a clever-but-broken set-based dedup might +/// accidentally drop legitimate duplicates the user wrote intentionally. +#[test] +fn multiple_distinct_inline_styles_each_appear_once_in_order() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styles: [ + '.a { color: red; }', + '.b { color: green; }', + '.c { color: blue; }', + ], +}) +export class MultiStylesComponent {} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + for needle in [".a { color: red; }", ".b { color: green; }", ".c { color: blue; }"] { + assert_eq!( + metadata.matches(needle).count(), + 1, + "`{needle}` should appear exactly once. Metadata:\n{metadata}" + ); + } + // Order: a < b < c in the output text. + let pa = metadata.find(".a {").expect("missing .a"); + let pb = metadata.find(".b {").expect("missing .b"); + let pc = metadata.find(".c {").expect("missing .c"); + assert!(pa < pb && pb < pc, "Styles should appear in source order. Metadata:\n{metadata}"); +} + +/// When source has BOTH `styles:[...]` AND `styleUrls:[...]`, neither the +/// inline nor the resolved-external content should be duplicated, and each +/// must be represented exactly once. +#[test] +fn no_duplicates_when_inline_styles_and_styleurls_coexist() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styles: ['.inline-1 {}', '.inline-2 {}'], + styleUrls: ['./external.css'], +}) +export class MixedStylesComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./external.css".to_string(), vec![".external {}".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + for needle in [".inline-1 {}", ".inline-2 {}", ".external {}"] { + assert_eq!( + metadata.matches(needle).count(), + 1, + "`{needle}` should appear exactly once. Metadata:\n{metadata}" + ); + } +} + +/// When source has ONLY `styleUrls` (no inline styles), each resolved style +/// should appear once and only once. +#[test] +fn no_duplicates_in_pure_styleurls_resolution() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrls: ['./a.css', './b.css'], +}) +export class PureStyleUrlsComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./a.css".to_string(), vec![".a {}".to_string()]); + styles.insert("./b.css".to_string(), vec![".b {}".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + for needle in [".a {}", ".b {}"] { + assert_eq!( + metadata.matches(needle).count(), + 1, + "`{needle}` should appear exactly once. Metadata:\n{metadata}" + ); + } +} + +// ================================================================= +// Bug 2 (empty-style filter) — additional coverage variants +// ================================================================= + +/// Non-empty styles should survive while empty/whitespace-only ones are +/// dropped. Mirrors Angular's `style.trim().length > 0` filter. +#[test] +fn mixed_empty_and_nonempty_styles_keeps_only_nonempty() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrls: ['./real.css', './empty.css', './whitespace.css'], +}) +export class MixedStyles {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./real.css".to_string(), vec![".real { color: red; }".to_string()]); + styles.insert("./empty.css".to_string(), vec![String::new()]); + styles.insert("./whitespace.css".to_string(), vec![" \t\n ".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + metadata.contains(".real { color: red; }"), + "Non-empty style should survive. Got:\n{metadata}" + ); + // The empty styles should be dropped — no empty quoted string literal. + assert!( + !metadata.contains(r#""""#), + "Empty string literals should not appear in the styles array. Got:\n{metadata}" + ); + // Catch both escaped and raw forms of the whitespace-only string. + assert!( + !metadata.contains(" \t\n ") && !metadata.contains(r#"" \t\n ""#), + "Whitespace-only style should be filtered. Got:\n{metadata}" + ); +} + +/// Inline source `styles:['']` (empty string inside the styles array of the +/// decorator) should also be filtered. Without this, the `styles: [""]` +/// literal survives into the metadata and TestBed receives a junk empty +/// stylesheet on recompilation. +#[test] +fn empty_inline_style_string_is_filtered_out() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styles: [''], +}) +export class EmptyInlineComponent {} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + !metadata.contains("styles:"), + "When the only style is empty, the styles key should be omitted entirely. Got:\n{metadata}" + ); +} + +/// `styleUrl` (singular) with empty/whitespace content should produce no +/// `styles` key — same filter applies regardless of the source key spelling. +#[test] +fn empty_styleurl_singular_drops_styles_key() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', + styleUrl: './empty.css', +}) +export class EmptyStyleUrlComponent {} +"#; + + let mut styles = HashMap::new(); + styles.insert("./empty.css".to_string(), vec![" ".to_string()]); + let resources = ResolvedResources { templates: HashMap::new(), styles }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + !metadata.contains("styles:") && !metadata.contains("styleUrl"), + "Empty styleUrl should produce no styles key and no leftover styleUrl. Got:\n{metadata}" + ); +} + +/// Property ordering after `templateUrl` → `template` should match Angular's +/// reference `transformDecoratorResources`. Angular uses `Map.delete('templateUrl')` +/// followed by `Map.set('template', …)`, which appends `template` at the end of +/// the Map's insertion order when no existing `template` key is present. +/// +/// Concretely, source `{ selector, templateUrl, encapsulation }` must emit +/// `{ selector, encapsulation, template }` — not `{ selector, template, encapsulation }`. +/// This is the form `e2e/compare` checks against ngc's output via string equality. +#[test] +fn template_replacement_lands_at_end_matching_angular_map_semantics() { + let source = r#" +import { Component, ViewEncapsulation } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + templateUrl: './tmpl.html', + encapsulation: ViewEncapsulation.None, +}) +export class OrderedComponent {} +"#; + + let mut templates = HashMap::new(); + templates.insert("./tmpl.html".to_string(), "

".to_string()); + let resources = ResolvedResources { templates, styles: HashMap::new() }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + let normalized = metadata.replace([' ', '\n', '\t'], ""); + let selector_pos = normalized.find("selector:").expect("selector should be present"); + let encapsulation_pos = + normalized.find("encapsulation:").expect("encapsulation should be present"); + let template_pos = normalized.find("template:").expect("template should be present"); + + assert!( + selector_pos < encapsulation_pos, + "Source ordering of non-resource keys should be preserved. Got:\n{metadata}" + ); + assert!( + encapsulation_pos < template_pos, + "Resolved template should appear AFTER all surviving source keys (Angular's \ + Map.delete + Map.set appends template to end of insertion order). Got:\n{metadata}" + ); + assert!( + !normalized.contains("templateUrl"), + "templateUrl literal should not appear. Got:\n{metadata}" + ); +} + +/// If the source decorator illegally contains BOTH inline `template` and +/// `templateUrl`, Angular's `Map.delete('templateUrl')` + `Map.set('template', …)` +/// semantics produce a single `template` key (Map.set on an existing key +/// overwrites in place; the original position is preserved). OXC must not emit +/// duplicate `template:` literals — that's invalid JS object syntax in strict mode +/// and a divergence from `ngc`'s output that would fail the e2e string-equality +/// comparison. +#[test] +fn source_with_both_template_and_template_url_emits_single_template_key() { + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '', + templateUrl: './tmpl.html', +}) +export class DoubleTemplateComponent {} +"#; + + let mut templates = HashMap::new(); + templates.insert("./tmpl.html".to_string(), "".to_string()); + let resources = ResolvedResources { templates, styles: HashMap::new() }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + let template_count = metadata.matches("template:").count(); + assert_eq!( + template_count, 1, + "Expected exactly ONE `template:` key in setClassMetadata (Angular's \ + Map.set semantics overwrite, not duplicate). Got {template_count} in:\n{metadata}" + ); + assert!( + !metadata.contains("templateUrl"), + "templateUrl literal should not appear after inlining. Got:\n{metadata}" + ); + + // ngc AOT prefers `templateUrl` content over inline `template` when both + // are present — see `parseTemplateDeclaration` in + // `compiler-cli/src/ngtsc/annotations/component/src/resources.ts` which + // checks `component.has('templateUrl')` first and returns immediately. + // OXC is an AOT-equivalent single-file compiler so it must match that + // precedence (ngc JIT diverges, preferring inline — irrelevant here). + assert!( + metadata.contains(""), + "templateUrl content should win in AOT mode (ngc parity). Got:\n{metadata}" + ); + assert!( + !metadata.contains(""), + "Inline template should be discarded when templateUrl is also present \ + (matches ngc AOT `parseTemplateDeclaration`). Got:\n{metadata}" + ); +} + +/// Resource-key inlining must apply ONLY to `@Component` decorators. Angular's +/// reference impl gates on `if (dec.name !== 'Component') return dec;` at the +/// top of `transformDecoratorResources`. If we don't check the decorator name, +/// other decorators that happen to use resource-shaped keys (legal TypeScript, +/// just nonsensical) get their literals corrupted. +/// +/// This test exercises that via a constructor parameter decorator — `@Inject` +/// metadata goes through `build_decorator_metadata_array` with `decorator_idx == 0` +/// and would hit the resource-rewriting path without a name check. +#[test] +fn non_component_decorator_with_resource_shaped_keys_passes_through_untouched() { + let source = r#" +import { Component, Inject } from '@angular/core'; + +@Component({ + selector: 'test-cmp', + template: '
', +}) +export class CtorComponent { + constructor(@Inject({ templateUrl: './bogus.html', styleUrls: ['./bogus.css'] }) service: any) {} +} +"#; + + let resources = ResolvedResources { templates: HashMap::new(), styles: HashMap::new() }; + let code = run_with_resources(source, resources); + + // The `@Inject(...)` literal lands inside the ctorParameters → decorators + // → args array of setClassMetadata. Its `templateUrl` / `styleUrls` keys + // must survive verbatim — they're an opaque DI token, not a component config. + assert!( + code.contains("templateUrl:\"./bogus.html\"") + || code.contains("templateUrl: './bogus.html'"), + "@Inject's templateUrl literal must survive verbatim in ctorParameters. Got:\n{code}" + ); + assert!( + code.contains("styleUrls:[\"./bogus.css\"]") || code.contains("styleUrls:['./bogus.css']"), + "@Inject's styleUrls literal must survive verbatim in ctorParameters. Got:\n{code}" + ); +} + +/// Spread elements in the source decorator literal (`@Component({ ...config, … })`) +/// pass through verbatim to `setClassMetadata`. This is a known limitation: OXC +/// doesn't statically evaluate the spread argument, so resource fields living +/// inside the spread can leak past `componentNeedsResolution` at runtime. +/// +/// Angular's reference impl operates on a `Map` already +/// produced by the annotation handler, which has resolved spreads upstream. +/// Until OXC has equivalent pre-extraction spread resolution, the safe behavior +/// is "preserve unchanged" — anything else would risk losing genuine user data. +/// This test locks that in so a future change can't silently regress it. +#[test] +fn spread_elements_in_component_decorator_pass_through_unchanged() { + let source = r#" +import { Component } from '@angular/core'; + +const baseConfig = { changeDetection: 0 }; + +@Component({ + ...baseConfig, + selector: 'test-cmp', + templateUrl: './tmpl.html', +}) +export class SpreadComponent {} +"#; + + let mut templates = HashMap::new(); + templates.insert("./tmpl.html".to_string(), "

".to_string()); + let resources = ResolvedResources { templates, styles: HashMap::new() }; + + let code = run_with_resources(source, resources); + let metadata = extract_metadata_args(&code); + + assert!( + metadata.contains("...baseConfig"), + "Spread element must be preserved verbatim. Got:\n{metadata}" + ); + assert!( + !metadata.contains("templateUrl"), + "templateUrl outside the spread must still be inlined. Got:\n{metadata}" + ); + assert!( + metadata.contains("template:\"

\""), + "Resolved template content must be present. Got:\n{metadata}" + ); +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 84a741019..0bad46aad 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -6271,12 +6271,23 @@ export class TestComponent {} transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); - let normalized = result.code.replace([' ', '\n', '\t'], ""); - // Angular TS compiler omits standalone when true (runtime defaults to true via ?? true) - assert!( - !normalized.contains("standalone:true"), - "Standalone component should NOT emit `standalone:true` (runtime defaults to true). Output:\n{}", - result.code + // Scope the check to the ɵɵdefineComponent({...}) literal. The setClassMetadata + // emission (now on by default, matching ngc) faithfully preserves the user's + // source `standalone: true` for TestBed — that's expected and not what this test + // is asserting against. + let define_start = + result.code.find("ɵɵdefineComponent(").expect("expected ɵɵdefineComponent call in output"); + let define_end = result.code[define_start..] + .find("});") + .map(|i| define_start + i) + .unwrap_or(result.code.len()); + let define_block = &result.code[define_start..define_end]; + let normalized_define = define_block.replace([' ', '\n', '\t'], ""); + assert!( + !normalized_define.contains("standalone:true"), + "ɵɵdefineComponent should NOT emit `standalone:true` (runtime defaults to true). \ + defineComponent block:\n{}", + define_block ); } diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_inline_styles.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_inline_styles.snap index 77c8d5f37..8d7ce9a82 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_inline_styles.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_inline_styles.snap @@ -24,3 +24,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:StyledComponent,select (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(StyledComponent, {className:"StyledComponent",filePath:"styled.component.ts",lineNumber:9})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(StyledComponent, + [{type:Component,args:[{selector:"app-styled",template:"
Hello
", + styles:[".container { color: red; }"]}]}],null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_multiple_styles.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_multiple_styles.snap index 5a5de2145..56391a367 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_multiple_styles.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_with_multiple_styles.snap @@ -23,3 +23,9 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:MultiStyledComponent,s (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(MultiStyledComponent, {className:"MultiStyledComponent",filePath:"multi-styled.component.ts",lineNumber:12})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(MultiStyledComponent, + [{type:Component,args:[{selector:"app-multi-styled",template:"
Content
", + styles:[".first { color: blue; }",".second { background: white; }"]}]}], + null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_without_styles.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_without_styles.snap index b946c7ec6..928991476 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_without_styles.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__component_without_styles.snap @@ -23,3 +23,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:NoStylesComponent,sele (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(NoStylesComponent, {className:"NoStylesComponent",filePath:"no-styles.component.ts",lineNumber:8})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(NoStylesComponent, + [{type:Component,args:[{selector:"app-no-styles",template:"
No styles
"}]}], + null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__event_before_property_in_bindings.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__event_before_property_in_bindings.snap index adab899a8..ae89c6bf2 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__event_before_property_in_bindings.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__event_before_property_in_bindings.snap @@ -31,3 +31,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:9})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",template:"", + standalone:true}]}],null,null)); +})(); 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 eaaae3f6f..a3532cc06 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 @@ -35,3 +35,9 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:MyComponent,selectors: (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(MyComponent, {className:"MyComponent",filePath:"test.component.ts",lineNumber:22})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(MyComponent, + [{type:Component,args:[{selector:"my-component",template:"",hostDirectives:[{directive:HostDir, + inputs:["valueAlias","colorAlias: customColorAlias"],outputs:["openedAlias", + "closedAlias: customClosedAlias"]}],standalone:false}]}],null,null)); +})(); 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 605d28956..f9eee3784 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 @@ -34,3 +34,9 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:MyComponent,selectors: (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(MyComponent, {className:"MyComponent",filePath:"test.component.ts",lineNumber:22})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(MyComponent, + [{type:Component,args:[{selector:"my-component",template:"",hostDirectives:[{directive:HostDir, + inputs:["value","color: colorAlias"],outputs:["opened","closed: closedAlias"]}], + standalone:false}]}],null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap index 5c7e17110..1975f3673 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__ngfor_attribute_ordering.snap @@ -36,3 +36,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:9})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",template:"
  • {{item}}
  • ", + standalone:true}]}],null,null)); +})(); 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 1e2e10bd3..14d726590 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 @@ -23,3 +23,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:10})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], + null,null)); +})(); 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 d4150cdeb..82c0ed1d7 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 @@ -26,3 +26,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:10})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], + null,null)); +})(); 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 a262b7d25..95dd567dd 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 @@ -21,3 +21,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:10})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], + null,null)); +})(); 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 098bcea65..c8637a0fa 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 @@ -21,3 +21,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:10})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], + null,null)); +})(); 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 667f5a6b4..52fd1b610 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 @@ -21,3 +21,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent, {className:"TestComponent",filePath:"test.component.ts",lineNumber:10})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, + [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], + null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap index b0297f59c..6dc2b12b3 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__selector_attrs_const_emission.snap @@ -26,3 +26,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:BadgeComponent,selecto (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(BadgeComponent, {className:"BadgeComponent",filePath:"badge.component.ts",lineNumber:9})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(BadgeComponent, + [{type:Component,args:[{selector:"span[bitBadge]",template:"", + standalone:true}]}],null,null)); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_uses_full_mode.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_uses_full_mode.snap index 385a018a9..5798028e9 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_uses_full_mode.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__standalone_component_uses_full_mode.snap @@ -49,3 +49,8 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:ExternalComponent,sele (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(ExternalComponent, {className:"ExternalComponent",filePath:"test.component.ts",lineNumber:9})); })(); +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(ExternalComponent, + [{type:Component,args:[{selector:"app-external",template:"

    {{ title }}

      @for (item of items; track item) {
    • {{ item }}
    • }
    ", + standalone:true}]}],null,null)); +})(); diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 375efcc0c..e748bbadb 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -820,10 +820,12 @@ export interface TransformOptions { /** * Emit setClassMetadata() calls for TestBed support. * - * When true, generates `ɵɵsetClassMetadata()` calls wrapped in a dev-mode guard. - * This preserves original decorator information for TestBed's recompilation APIs. + * When true, generates `ɵɵsetClassMetadata()` calls wrapped in a dev-mode guard + * (`(typeof ngDevMode === "undefined" || ngDevMode) && …`). Production bundles + * tree-shake the guarded call. Preserves original decorator information for + * TestBed's recompilation APIs. * - * Default: false (metadata is dev-only and usually stripped in production) + * Default: true — matches `ngc`, which always emits class metadata. */ emitClassMetadata?: boolean /** diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index cf2caba9d..f0a787a72 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -184,10 +184,12 @@ pub struct TransformOptions { /// Emit setClassMetadata() calls for TestBed support. /// - /// When true, generates `ɵɵsetClassMetadata()` calls wrapped in a dev-mode guard. - /// This preserves original decorator information for TestBed's recompilation APIs. + /// When true, generates `ɵɵsetClassMetadata()` calls wrapped in a dev-mode guard + /// (`(typeof ngDevMode === "undefined" || ngDevMode) && …`). Production bundles + /// tree-shake the guarded call. Preserves original decorator information for + /// TestBed's recompilation APIs. /// - /// Default: false (metadata is dev-only and usually stripped in production) + /// Default: true — matches `ngc`, which always emits class metadata. pub emit_class_metadata: Option, /// Minify final component styles before emitting them into `styles: [...]`. @@ -236,7 +238,7 @@ impl From for RustTransformOptions { // Resolved imports for host directives resolved_imports: options.resolved_imports, // Class metadata for TestBed support - emit_class_metadata: options.emit_class_metadata.unwrap_or(false), + emit_class_metadata: options.emit_class_metadata.unwrap_or(true), minify_component_styles: options.minify_component_styles.unwrap_or(false), } } @@ -1987,8 +1989,13 @@ pub fn compile_class_metadata_sync( // Build decorators array: [{ type: DecoratorClass, args: [...] }] let decorator_ref = decorator; - let decorators_expr = - core_build_decorator_metadata_array(&allocator, &[decorator_ref], Some(&source)); + let decorators_expr = core_build_decorator_metadata_array( + &allocator, + &[decorator_ref], + Some(&source), + None, + None, + ); // Build constructor parameters metadata // This standalone API doesn't have full transform pipeline context (constructor deps diff --git a/napi/angular-compiler/test/class-metadata-default.test.ts b/napi/angular-compiler/test/class-metadata-default.test.ts new file mode 100644 index 000000000..c62742e1b --- /dev/null +++ b/napi/angular-compiler/test/class-metadata-default.test.ts @@ -0,0 +1,86 @@ +import type { Plugin, PluginOption } from 'vite' +import { describe, expect, it } from 'vitest' + +import { angular } from '../vite-plugin/index.js' +import type { PluginOptions } from '../vite-plugin/index.js' + +const COMPONENT_SOURCE = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '
    Hello
    ', + }) + export class AppComponent {} +` + +function getAngularPlugin(options: PluginOptions = {}): Plugin { + const plugin = (angular(options) as PluginOption[]).find( + (candidate): candidate is Plugin => + !!candidate && + typeof candidate === 'object' && + 'name' in candidate && + candidate.name === '@oxc-angular/vite', + ) + + if (!plugin) { + throw new Error('Failed to find @oxc-angular/vite plugin') + } + + return plugin +} + +async function transform(options: PluginOptions = {}): Promise { + const plugin = getAngularPlugin(options) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + + const result = await plugin.transform.handler.call( + { + error(message: string) { + throw new Error(message) + }, + warn() {}, + } as any, + COMPONENT_SOURCE, + 'app.component.ts', + ) + + if (!result || typeof result !== 'object' || !('code' in result)) { + throw new Error('Expected transform result with code') + } + + return result.code as string +} + +describe('@oxc-angular/vite class metadata default (ngc parity)', () => { + it('emits ɵsetClassMetadata by default — matching ngc', async () => { + const code = await transform() + + expect(code).toContain('setClassMetadata') + }) + + it('wraps ɵsetClassMetadata in the ngDevMode guard so production strips it', async () => { + const code = await transform() + + // The guarded call looks like: + // ((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(...) + // Assert ngDevMode appears within ~200 chars *before* setClassMetadata, so we + // don't false-positive against the unrelated setClassDebugInfo guard. + expect(code).toMatch(/ngDevMode[\s\S]{0,200}?setClassMetadata/) + }) + + it('omits ɵsetClassMetadata when explicitly disabled', async () => { + const code = await transform({ emitClassMetadata: false }) + + expect(code).not.toContain('setClassMetadata') + }) + + it('emits ɵsetClassMetadata when explicitly enabled', async () => { + const code = await transform({ emitClassMetadata: true }) + + expect(code).toContain('setClassMetadata') + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 5bb2ce6b5..947c1f6b2 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -108,6 +108,20 @@ export interface PluginOptions { /** Optional callback to transform template content before compilation. Applied during both initial build and HMR. */ templateTransform?: (content: string, filePath: string) => string + + /** + * Emit `ɵsetClassMetadata()` calls for TestBed support. + * + * Mirrors `ngc`'s behavior: when enabled, the original decorator metadata is + * preserved on the compiled class wrapped in `(typeof ngDevMode === "undefined" + * || ngDevMode) && …`, so production bundles tree-shake it away. Required for + * TestBed APIs that recompile components with provider overrides. Resolved + * `templateUrl`/`styleUrls` are inlined into the metadata as `template`/`styles` + * to satisfy Angular's JIT `componentNeedsResolution` check. + * + * Default: `true` — matches `ngc`, which always emits class metadata. + */ + emitClassMetadata?: boolean } // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler @@ -182,6 +196,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { zoneless: options.zoneless ?? false, fileReplacements, angularVersion: options.angularVersion, + emitClassMetadata: options.emitClassMetadata ?? true, } let resolvedConfig: ResolvedConfig @@ -618,6 +633,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { hmr: pluginOptions.liveReload && watchMode && !isSSR, angularVersion: pluginOptions.angularVersion, minifyComponentStyles: getMinifyComponentStyles(this as any), + emitClassMetadata: pluginOptions.emitClassMetadata, } const result = await transformAngularFile(code, actualId, transformOptions, resources)