From 2d4c8490b25de94b79a0d162605e0cfd5c8c2ffc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 8 Mar 2026 22:02:28 +0800 Subject: [PATCH 1/3] feat: implement JIT compilation output for transformAngularFile When `jit: true` is passed, the compiler now produces Angular JIT-compatible output instead of AOT-compiled code. This matches the output format of Angular CLI's JitCompilation class: - Decorator downleveling via `__decorate` from tslib - templateUrl/styleUrl replaced with `angular:jit:template:file;` imports - Constructor params emitted as `static ctorParameters` for runtime DI - Class restructured to `let X = class X {}; X = __decorate([...], X);` - Templates are NOT compiled (runtime JIT compiler handles that) - Import elision disabled (ctor param types needed at runtime) Closes #97 Co-Authored-By: Claude Opus 4.6 --- .../src/component/transform.rs | 589 +++++++++++++++++- .../tests/integration_test.rs | 388 ++++++++++++ ...gration_test__jit_class_restructuring.snap | 17 + ...ntegration_test__jit_constructor_deps.snap | 22 + .../integration_test__jit_directive.snap | 17 + .../integration_test__jit_full_component.snap | 33 + ...integration_test__jit_inline_template.snap | 16 + .../integration_test__jit_style_url.snap | 17 + .../integration_test__jit_template_url.snap | 17 + 9 files changed, 1113 insertions(+), 3 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_class_restructuring.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_constructor_deps.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_inline_template.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_style_url.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_template_url.snap diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 348163daf..58f9c27ea 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -7,12 +7,12 @@ use std::collections::HashMap; use oxc_allocator::{Allocator, Vec as OxcVec}; use oxc_ast::ast::{ - Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, ImportOrExportKind, - Statement, + Argument, ArrayExpressionElement, Declaration, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, PropertyKey, Statement, }; use oxc_diagnostics::OxcDiagnostic; use oxc_parser::Parser; -use oxc_span::{Atom, SourceType, Span}; +use oxc_span::{Atom, GetSpan, SourceType, Span}; use rustc_hash::FxHashMap; use crate::optimizer::{Edit, apply_edits}; @@ -643,6 +643,584 @@ fn find_last_import_end(program_body: &[Statement<'_>]) -> Option { last_import_end.map(|pos| pos as usize) } +// ============================================================================ +// JIT Compilation Transform +// ============================================================================ + +/// Identifies which Angular decorator type a class has. +#[derive(Debug, Clone, Copy)] +enum AngularDecoratorKind { + Component, + Directive, + Pipe, + Injectable, + NgModule, +} + +/// Information about an Angular-decorated class for JIT transformation. +struct JitClassInfo { + /// The class name. + class_name: String, + /// Span of the decorator (including @). + decorator_span: Span, + /// Start of the statement (includes export keyword if present). + stmt_start: u32, + /// Start of the class keyword. + class_start: u32, + /// End of the class body (the closing `}`). + class_body_end: u32, + /// Whether the class is exported (not default). + is_exported: bool, + /// Whether the class is export default. + is_default_export: bool, + /// Constructor parameter info for ctorParameters. + ctor_params: std::vec::Vec, + /// The modified decorator expression text for __decorate call. + decorator_text: String, +} + +/// Constructor parameter info for JIT ctorParameters generation. +struct JitCtorParam { + /// The type name (if resolvable to a runtime value). + type_name: Option, + /// Angular decorators on the parameter, as source text spans. + decorators: std::vec::Vec, +} + +/// A single decorator on a constructor parameter. +struct JitParamDecorator { + /// The decorator name (e.g., "Optional", "Inject"). + name: String, + /// The decorator arguments as source text (e.g., "TOKEN" for @Inject(TOKEN)). + args: Option, +} + +/// Find any Angular decorator on a class and return its kind and the decorator reference. +fn find_angular_decorator<'a>( + class: &'a oxc_ast::ast::Class<'a>, +) -> Option<(AngularDecoratorKind, &'a oxc_ast::ast::Decorator<'a>)> { + for decorator in &class.decorators { + if let Expression::CallExpression(call) = &decorator.expression { + let name = match &call.callee { + Expression::Identifier(id) => Some(id.name.as_str()), + Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()), + _ => None, + }; + match name { + Some("Component") => return Some((AngularDecoratorKind::Component, decorator)), + Some("Directive") => return Some((AngularDecoratorKind::Directive, decorator)), + Some("Pipe") => return Some((AngularDecoratorKind::Pipe, decorator)), + Some("Injectable") => return Some((AngularDecoratorKind::Injectable, decorator)), + Some("NgModule") => return Some((AngularDecoratorKind::NgModule, decorator)), + _ => {} + } + } + } + None +} + +/// Extract constructor parameter info for JIT ctorParameters generation. +fn extract_jit_ctor_params( + source: &str, + class: &oxc_ast::ast::Class<'_>, +) -> std::vec::Vec { + use oxc_ast::ast::{ClassElement, MethodDefinitionKind}; + + let constructor = class.body.body.iter().find_map(|element| { + if let ClassElement::MethodDefinition(method) = element { + if method.kind == MethodDefinitionKind::Constructor { + return Some(method); + } + } + None + }); + + let Some(ctor) = constructor else { + return std::vec::Vec::new(); + }; + + let mut params = std::vec::Vec::new(); + for param in &ctor.value.params.items { + // Extract type name from type annotation (directly on FormalParameter) + let type_name = param + .type_annotation + .as_ref() + .and_then(|ann| extract_type_name_from_annotation(&ann.type_annotation)); + + // Extract Angular decorators + let mut decorators = std::vec::Vec::new(); + for decorator in ¶m.decorators { + if let Expression::CallExpression(call) = &decorator.expression { + let dec_name = match &call.callee { + Expression::Identifier(id) => Some(id.name.to_string()), + _ => None, + }; + if let Some(name) = dec_name { + match name.as_str() { + "Inject" | "Optional" | "SkipSelf" | "Self" | "Host" | "Attribute" => { + let args = if call.arguments.is_empty() { + None + } else { + // Extract args from source + let args_start = call.arguments.first().unwrap().span().start; + let args_end = call.arguments.last().unwrap().span().end; + Some(source[args_start as usize..args_end as usize].to_string()) + }; + decorators.push(JitParamDecorator { name, args }); + } + _ => {} + } + } + } else if let Expression::Identifier(id) = &decorator.expression { + let name = id.name.to_string(); + match name.as_str() { + "Optional" | "SkipSelf" | "Self" | "Host" => { + decorators.push(JitParamDecorator { name, args: None }); + } + _ => {} + } + } + } + + params.push(JitCtorParam { type_name, decorators }); + } + + params +} + +/// Extract a type name from a TypeScript type annotation for JIT ctorParameters. +fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>) -> Option { + match type_annotation { + oxc_ast::ast::TSType::TSTypeReference(type_ref) => { + // Simple type reference: `SomeClass` + match &type_ref.type_name { + oxc_ast::ast::TSTypeName::IdentifierReference(id) => Some(id.name.to_string()), + oxc_ast::ast::TSTypeName::QualifiedName(qn) => { + // Qualified name: `ns.SomeClass` + Some(format!("{}.{}", extract_ts_type_name_left(&qn.left), qn.right.name)) + } + _ => None, + } + } + oxc_ast::ast::TSType::TSUnionType(union) => { + // For union types like `T | null`, try to find the non-null type + for t in &union.types { + if !matches!(t, oxc_ast::ast::TSType::TSNullKeyword(_)) { + return extract_type_name_from_annotation(t); + } + } + None + } + _ => None, + } +} + +/// Helper to extract the string from a TSTypeName (left side of qualified name). +fn extract_ts_type_name_left(name: &oxc_ast::ast::TSTypeName<'_>) -> String { + match name { + oxc_ast::ast::TSTypeName::IdentifierReference(id) => id.name.to_string(), + oxc_ast::ast::TSTypeName::QualifiedName(qn) => { + format!("{}.{}", extract_ts_type_name_left(&qn.left), qn.right.name) + } + _ => String::new(), + } +} + +/// Build the ctorParameters static property text. +fn build_ctor_parameters_text(params: &[JitCtorParam]) -> Option { + if params.is_empty() { + return None; + } + + let mut entries = std::vec::Vec::new(); + for param in params { + let mut parts = std::vec::Vec::new(); + + // type + if let Some(ref type_name) = param.type_name { + parts.push(format!("type: {}", type_name)); + } else { + parts.push("type: undefined".to_string()); + } + + // decorators + if !param.decorators.is_empty() { + let dec_strs: std::vec::Vec = param + .decorators + .iter() + .map(|d| { + if let Some(ref args) = d.args { + format!("{{ type: {}, args: [{}] }}", d.name, args) + } else { + format!("{{ type: {} }}", d.name) + } + }) + .collect(); + parts.push(format!("decorators: [{}]", dec_strs.join(", "))); + } + + entries.push(format!("{{ {} }}", parts.join(", "))); + } + + Some(format!("static ctorParameters = () => [\n {}\n]", entries.join(",\n "))) +} + +/// Build the modified decorator expression text for JIT __decorate call. +/// +/// For @Component decorators, replaces: +/// - `templateUrl: './path'` → `template: __NG_CLI_RESOURCE__N` +/// - `styleUrl: './path'` → `styles: [__NG_CLI_RESOURCE__N]` +/// - `styleUrls: ['./a', './b']` → `styles: [__NG_CLI_RESOURCE__N, __NG_CLI_RESOURCE__M]` +fn build_jit_decorator_text( + source: &str, + decorator: &oxc_ast::ast::Decorator<'_>, + decorator_kind: AngularDecoratorKind, + resource_counter: &mut u32, + resource_imports: &mut std::vec::Vec<(String, String)>, // (import_name, specifier) +) -> String { + let expr_start = decorator.expression.span().start as usize; + let expr_end = decorator.expression.span().end as usize; + let expr_text = &source[expr_start..expr_end]; + + // For non-Component decorators, just return the expression text as-is + if !matches!(decorator_kind, AngularDecoratorKind::Component) { + return expr_text.to_string(); + } + + // For Component decorators, check for resource properties to replace + let Expression::CallExpression(call) = &decorator.expression else { + return expr_text.to_string(); + }; + + let Some(config_arg) = call.arguments.first() else { + return expr_text.to_string(); + }; + + let Argument::ObjectExpression(config_obj) = config_arg else { + return expr_text.to_string(); + }; + + // Collect edits within the expression text + let mut edits: std::vec::Vec<(usize, usize, String)> = std::vec::Vec::new(); + + for prop in &config_obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + let key_name = match &prop.key { + PropertyKey::StaticIdentifier(id) => Some(id.name.as_str()), + PropertyKey::StringLiteral(s) => Some(s.value.as_str()), + _ => None, + }; + + match key_name { + Some("templateUrl") => { + // Extract the URL string value + if let Expression::StringLiteral(s) = &prop.value { + let import_name = format!("__NG_CLI_RESOURCE__{}", *resource_counter); + let specifier = format!("angular:jit:template:file;{}", s.value.as_str()); + resource_imports.push((import_name.clone(), specifier)); + + // Replace the entire property: `templateUrl: './app.html'` → `template: __NG_CLI_RESOURCE__0` + let prop_start = prop.span.start as usize - expr_start; + let prop_end = prop.span.end as usize - expr_start; + edits.push((prop_start, prop_end, format!("template: {}", import_name))); + + *resource_counter += 1; + } + } + Some("styleUrl") => { + // Single style URL + if let Expression::StringLiteral(s) = &prop.value { + let import_name = format!("__NG_CLI_RESOURCE__{}", *resource_counter); + let specifier = format!("angular:jit:style:file;{}", s.value.as_str()); + resource_imports.push((import_name.clone(), specifier)); + + let prop_start = prop.span.start as usize - expr_start; + let prop_end = prop.span.end as usize - expr_start; + edits.push((prop_start, prop_end, format!("styles: [{}]", import_name))); + + *resource_counter += 1; + } + } + Some("styleUrls") => { + // Array of style URLs + if let Expression::ArrayExpression(arr) = &prop.value { + let mut style_refs = std::vec::Vec::new(); + for elem in &arr.elements { + if let ArrayExpressionElement::StringLiteral(s) = elem { + let import_name = + format!("__NG_CLI_RESOURCE__{}", *resource_counter); + let specifier = + format!("angular:jit:style:file;{}", s.value.as_str()); + resource_imports.push((import_name.clone(), specifier)); + style_refs.push(import_name); + *resource_counter += 1; + } + } + + let prop_start = prop.span.start as usize - expr_start; + let prop_end = prop.span.end as usize - expr_start; + edits.push(( + prop_start, + prop_end, + format!("styles: [{}]", style_refs.join(", ")), + )); + } + } + _ => {} + } + } + } + + if edits.is_empty() { + return expr_text.to_string(); + } + + // Apply edits in reverse order to preserve positions + let mut result = expr_text.to_string(); + edits.sort_by(|a, b| b.0.cmp(&a.0)); + for (start, end, replacement) in edits { + result.replace_range(start..end, &replacement); + } + + result +} + +/// Transform an Angular TypeScript file in JIT (Just-In-Time) compilation mode. +/// +/// JIT mode produces output compatible with Angular's JIT runtime compiler: +/// - Decorators are downleveled using `__decorate` from tslib +/// - `templateUrl` is replaced with `angular:jit:template:file;` imports +/// - `styleUrl`/`styleUrls` are replaced with `angular:jit:style:file;` imports +/// - Constructor parameters are emitted as `ctorParameters` static property +/// - Templates are NOT compiled (the runtime JIT compiler handles that) +fn transform_angular_file_jit( + allocator: &Allocator, + path: &str, + source: &str, + _options: &TransformOptions, +) -> TransformResult { + let mut result = TransformResult::new(); + + // 1. Parse the TypeScript file + let source_type = SourceType::from_path(path).unwrap_or_default(); + let parser_ret = Parser::new(allocator, source, source_type).parse(); + + if !parser_ret.errors.is_empty() { + for error in parser_ret.errors { + result.diagnostics.push(OxcDiagnostic::error(error.to_string())); + } + } + + // 2. Import elision is DISABLED in JIT mode. + // JIT mode needs all imports preserved because constructor parameter types + // are referenced at runtime in ctorParameters. Angular's TS JIT transform + // patches TypeScript's import elision for the same reason. + + // 3. Walk AST to find Angular-decorated classes + let mut jit_classes: std::vec::Vec = std::vec::Vec::new(); + let mut resource_counter: u32 = 0; + let mut resource_imports: std::vec::Vec<(String, String)> = std::vec::Vec::new(); + + for stmt in &parser_ret.program.body { + let (class, stmt_start, is_exported, is_default_export) = match stmt { + Statement::ClassDeclaration(class) => { + (Some(class.as_ref()), class.span.start, false, false) + } + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + (Some(class.as_ref()), export.span.start, false, true) + } + _ => (None, 0, false, false), + }, + Statement::ExportNamedDeclaration(export) => match &export.declaration { + Some(Declaration::ClassDeclaration(class)) => { + (Some(class.as_ref()), export.span.start, true, false) + } + _ => (None, 0, false, false), + }, + _ => (None, 0, false, false), + }; + + let Some(class) = class else { continue }; + let Some(class_name) = class.id.as_ref().map(|id| id.name.to_string()) else { + continue; + }; + + let Some((decorator_kind, decorator)) = find_angular_decorator(class) else { + continue; + }; + + // Build modified decorator text (replaces templateUrl/styleUrl with resource imports) + let decorator_text = build_jit_decorator_text( + source, + decorator, + decorator_kind, + &mut resource_counter, + &mut resource_imports, + ); + + // Extract constructor parameters for ctorParameters + let ctor_params = extract_jit_ctor_params(source, class); + + jit_classes.push(JitClassInfo { + class_name, + decorator_span: decorator.span, + stmt_start, + class_start: class.span.start, + class_body_end: class.body.span.end, + is_exported, + is_default_export, + ctor_params, + decorator_text, + }); + + result.component_count += + if matches!(decorator_kind, AngularDecoratorKind::Component) { 1 } else { 0 }; + } + + if jit_classes.is_empty() { + // No Angular classes found, return source as-is + result.code = source.to_string(); + return result; + } + + // 4. Build edits + let mut edits: std::vec::Vec = std::vec::Vec::new(); + + // Build the additional imports text (tslib + resource imports) + let mut additional_imports = String::new(); + additional_imports.push_str("import { __decorate } from \"tslib\";\n"); + for (import_name, specifier) in &resource_imports { + additional_imports.push_str(&format!("import {} from \"{}\";\n", import_name, specifier)); + } + + // Insert additional imports after the last existing import + let ns_insert_pos = find_last_import_end(&parser_ret.program.body); + if let Some(insert_pos) = ns_insert_pos { + let bytes = source.as_bytes(); + let mut actual_pos = insert_pos; + while actual_pos < bytes.len() { + let c = bytes[actual_pos]; + if c == b'\n' { + actual_pos += 1; + break; + } else if c == b' ' || c == b'\t' || c == b'\r' { + actual_pos += 1; + } else { + break; + } + } + // Ensure insert position doesn't fall inside an import elision edit + for edit in &edits { + if (edit.start as usize) < actual_pos && (edit.end as usize) > actual_pos { + actual_pos = edit.end as usize; + } + } + edits.push(Edit::insert(actual_pos as u32, additional_imports).with_priority(10)); + } else { + edits.push(Edit::insert(0, additional_imports).with_priority(10)); + } + + // Process each Angular class - generate edits for class restructuring + // Also need to collect member/constructor decorator spans from the AST + // Build a lookup of class positions to match against JitClassInfo + for stmt in parser_ret.program.body.iter() { + let class = match stmt { + Statement::ClassDeclaration(class) => Some(class.as_ref()), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class.as_ref()), + _ => None, + }, + Statement::ExportNamedDeclaration(export) => match &export.declaration { + Some(Declaration::ClassDeclaration(class)) => Some(class.as_ref()), + _ => None, + }, + _ => None, + }; + + let Some(class) = class else { continue }; + + // Find the matching JitClassInfo by class start position + let Some(jit_info) = jit_classes.iter().find(|info| info.class_start == class.span.start) + else { + continue; + }; + + // 4a. Remove the Angular decorator (including @ and trailing whitespace) + { + let mut end = jit_info.decorator_span.end as usize; + let bytes = source.as_bytes(); + while end < bytes.len() { + let c = bytes[end]; + if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' { + end += 1; + } else { + break; + } + } + edits.push(Edit::delete(jit_info.decorator_span.start, end as u32)); + } + + // 4b. Remove member decorators (@Input, @Output, etc.) and constructor param decorators + { + let mut decorator_spans: std::vec::Vec = std::vec::Vec::new(); + super::decorator::collect_constructor_decorator_spans(class, &mut decorator_spans); + super::decorator::collect_member_decorator_spans(class, &mut decorator_spans); + for span in &decorator_spans { + let mut end = span.end as usize; + let bytes = source.as_bytes(); + while end < bytes.len() { + let c = bytes[end]; + if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' { + end += 1; + } else { + break; + } + } + edits.push(Edit::delete(span.start, end as u32)); + } + } + + // 4c. Class restructuring: `export class X` → `let X = class X` + if jit_info.is_exported || jit_info.is_default_export { + edits.push(Edit::replace( + jit_info.stmt_start, + jit_info.class_start, + format!("let {} = ", jit_info.class_name), + )); + } else { + edits.push(Edit::insert( + jit_info.class_start, + format!("let {} = ", jit_info.class_name), + )); + } + + // 4d. Add ctorParameters inside class body (before closing `}`) + if let Some(ctor_text) = build_ctor_parameters_text(&jit_info.ctor_params) { + edits.push(Edit::insert(jit_info.class_body_end - 1, format!("\n{};\n", ctor_text))); + } + + // 4e. After class body, add __decorate call and export + let mut after_class = format!( + ";\n{} = __decorate([\n {}\n], {});\n", + jit_info.class_name, jit_info.decorator_text, jit_info.class_name + ); + + if jit_info.is_exported { + after_class.push_str(&format!("export {{ {} }};\n", jit_info.class_name)); + } else if jit_info.is_default_export { + after_class.push_str(&format!("export default {};\n", jit_info.class_name)); + } + + edits.push(Edit::insert(jit_info.class_body_end, after_class)); + } + + // Apply all edits + result.code = apply_edits(source, edits); + + result +} + /// Transform an Angular TypeScript file. /// /// This function: @@ -670,6 +1248,11 @@ pub fn transform_angular_file( options: &TransformOptions, resolved_resources: Option<&ResolvedResources>, ) -> TransformResult { + // JIT mode uses a completely different code path + if options.jit { + return transform_angular_file_jit(allocator, path, source, options); + } + let mut result = TransformResult::new(); // 1. Parse the TypeScript file diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index feab4078a..b1127ac14 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -5857,3 +5857,391 @@ fn test_host_binding_pure_function_declarations_emitted() { } } } + +// ============================================================================ +// Standalone Emission Tests (Issue #95) +// ============================================================================ + +/// Test that a standalone component does NOT emit `standalone:true` in ɵɵdefineComponent. +/// +/// Angular's TS compiler (compiler.ts:96-98) only emits `standalone: false` when +/// isStandalone === false. When standalone is true, it's omitted because the Angular +/// runtime (definition.ts:637) defaults `standalone` to `true` via `?? true`. +/// +/// OXC matches this behavior exactly. +#[test] +fn test_standalone_component_omits_standalone_field() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-test', + standalone: true, + template: '
test
' +}) +export class TestComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "test.component.ts", source, &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 + ); +} + +/// Test that a non-standalone component emits `standalone:false` in ɵɵdefineComponent. +#[test] +fn test_non_standalone_component_emits_standalone_false() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-legacy', + standalone: false, + template: '
legacy
' +}) +export class LegacyComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let normalized = result.code.replace([' ', '\n', '\t'], ""); + assert!( + normalized.contains("standalone:false"), + "Non-standalone component MUST emit `standalone:false` in ɵɵdefineComponent. Output:\n{}", + result.code + ); +} + +/// Test that an implicit standalone component (Angular 19+ default) omits `standalone` field. +/// +/// Angular 19+ defaults `standalone` to `true`. The Angular TS compiler omits the field +/// when true, and the runtime defaults it via `?? true`. OXC matches this behavior. +#[test] +fn test_implicit_standalone_with_imports_omits_standalone_field() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-implicit', + imports: [NgIf], + template: '
implicit standalone
' +}) +export class ImplicitStandaloneComponent {} +"#; + + let options = ComponentTransformOptions::default(); + let result = transform_angular_file(&allocator, "test.component.ts", source, &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"), + "Implicit standalone component should NOT emit `standalone:true` (runtime defaults to true). Output:\n{}", + result.code + ); +} + +// ============================================================================ +// JIT Compilation Tests +// ============================================================================ + +#[test] +fn test_jit_component_with_inline_template() { + // When jit: true, the compiler should NOT compile templates. + // Instead, it should keep the decorator and downlevel it using __decorate. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '

Hello

', + standalone: true, +}) +export class AppComponent {} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have __decorate import from tslib + assert!( + result.code.contains("import { __decorate } from \"tslib\""), + "JIT output should import __decorate from tslib. Got:\n{}", + result.code + ); + + // Should NOT have ɵcmp or ɵfac (AOT-style definitions) + assert!( + !result.code.contains("ɵcmp") && !result.code.contains("ɵfac"), + "JIT output should NOT contain AOT definitions (ɵcmp/ɵfac). Got:\n{}", + result.code + ); + + // Should have __decorate call with Component + assert!( + result.code.contains("__decorate("), + "JIT output should use __decorate. Got:\n{}", + result.code + ); + + // Should keep the template property as-is (inline template) + assert!( + result.code.contains("template:"), + "JIT output should preserve inline template. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_inline_template", result.code); +} + +#[test] +fn test_jit_component_with_template_url() { + // When jit: true and templateUrl is used, it should be replaced with + // an import from angular:jit:template:file;./path + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + templateUrl: './app.html', + standalone: true, +}) +export class AppComponent {} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have resource import for template + assert!( + result.code.contains("angular:jit:template:file;./app.html"), + "JIT output should import template via angular:jit:template:file. Got:\n{}", + result.code + ); + + // Should replace templateUrl with template referencing the import + assert!( + !result.code.contains("templateUrl"), + "JIT output should replace templateUrl with template. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_template_url", result.code); +} + +#[test] +fn test_jit_component_with_style_url() { + // When jit: true and styleUrl/styleUrls is used, it should be replaced with + // imports from angular:jit:style:file;./path + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '

Hello

', + styleUrl: './app.css', +}) +export class AppComponent {} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have resource import for style + assert!( + result.code.contains("angular:jit:style:file;./app.css"), + "JIT output should import style via angular:jit:style:file. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_style_url", result.code); +} + +#[test] +fn test_jit_component_with_constructor_deps() { + // JIT compilation should generate ctorParameters for constructor dependencies + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; +import { TitleService } from './title.service'; + +@Component({ + selector: 'app-root', + template: '

Hello

', +}) +export class AppComponent { + constructor(private titleService: TitleService) {} +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have ctorParameters static property + assert!( + result.code.contains("ctorParameters"), + "JIT output should contain ctorParameters. Got:\n{}", + result.code + ); + + // Should reference TitleService type + assert!( + result.code.contains("TitleService"), + "JIT ctorParameters should reference dependency type. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_constructor_deps", result.code); +} + +#[test] +fn test_jit_component_class_restructuring() { + // JIT should restructure: export class X {} → let X = class X {}; X = __decorate([...], X); export { X }; + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '

Hello

', +}) +export class AppComponent { + title = 'app'; +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have let declaration + assert!( + result.code.contains("let AppComponent = class AppComponent"), + "JIT output should use 'let X = class X' pattern. Got:\n{}", + result.code + ); + + // Should have export statement + assert!( + result.code.contains("export { AppComponent }"), + "JIT output should have named export. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_class_restructuring", result.code); +} + +#[test] +fn test_jit_directive() { + // @Directive should also be JIT-transformed with __decorate + let allocator = Allocator::default(); + let source = r#" +import { Directive, Input } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class HighlightDirective { + @Input() color: string = 'yellow'; +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "highlight.directive.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have __decorate with Directive + assert!( + result.code.contains("__decorate("), + "JIT directive output should use __decorate. Got:\n{}", + result.code + ); + + // Should NOT have ɵdir or ɵfac + assert!( + !result.code.contains("ɵdir") && !result.code.contains("ɵfac"), + "JIT directive output should NOT contain AOT definitions. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_directive", result.code); +} + +#[test] +fn test_jit_full_component_example() { + // Full example matching the issue #97 scenario + let allocator = Allocator::default(); + let source = r#" +import { Component, signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { Lib1 } from 'lib1'; +import { TitleService } from './title.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, Lib1], + templateUrl: './app.html', + styleUrl: './app.css', +}) +export class App { + titleService; + title = signal('app'); + constructor(titleService: TitleService) { + this.titleService = titleService; + this.title.set(this.titleService.getTitle()); + } +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Should have all JIT characteristics + assert!( + result.code.contains("import { __decorate } from \"tslib\""), + "Missing __decorate import" + ); + assert!( + result.code.contains("angular:jit:template:file;./app.html"), + "Missing template resource import" + ); + assert!( + result.code.contains("angular:jit:style:file;./app.css"), + "Missing style resource import" + ); + assert!(result.code.contains("let App = class App"), "Missing class restructuring"); + assert!(result.code.contains("ctorParameters"), "Missing ctorParameters"); + assert!(result.code.contains("__decorate("), "Missing __decorate call"); + assert!(result.code.contains("export { App }"), "Missing named export"); + + // Should NOT have AOT output + assert!(!result.code.contains("ɵcmp"), "Should not contain ɵcmp"); + assert!(!result.code.contains("ɵfac"), "Should not contain ɵfac"); + assert!(!result.code.contains("defineComponent"), "Should not contain defineComponent"); + + insta::assert_snapshot!("jit_full_component", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_class_restructuring.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_class_restructuring.snap new file mode 100644 index 000000000..3f5f2f3e7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_class_restructuring.snap @@ -0,0 +1,17 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { __decorate } from "tslib"; + +let AppComponent = class AppComponent { + title = 'app'; +}; +AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: '

Hello

', +}) +], AppComponent); +export { AppComponent }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_constructor_deps.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_constructor_deps.snap new file mode 100644 index 000000000..418dddae1 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_constructor_deps.snap @@ -0,0 +1,22 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { TitleService } from './title.service'; +import { __decorate } from "tslib"; + +let AppComponent = class AppComponent { + constructor(private titleService: TitleService) {} + +static ctorParameters = () => [ + { type: TitleService } +]; +}; +AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: '

Hello

', +}) +], AppComponent); +export { AppComponent }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap new file mode 100644 index 000000000..d47d5a0e9 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap @@ -0,0 +1,17 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Directive, Input } from '@angular/core'; +import { __decorate } from "tslib"; + +let HighlightDirective = class HighlightDirective { + color: string = 'yellow'; +}; +HighlightDirective = __decorate([ + Directive({ + selector: '[appHighlight]', + standalone: true, +}) +], HighlightDirective); +export { HighlightDirective }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap new file mode 100644 index 000000000..43522bbed --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap @@ -0,0 +1,33 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component, signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { Lib1 } from 'lib1'; +import { TitleService } from './title.service'; +import { __decorate } from "tslib"; +import __NG_CLI_RESOURCE__0 from "angular:jit:template:file;./app.html"; +import __NG_CLI_RESOURCE__1 from "angular:jit:style:file;./app.css"; + +let App = class App { + titleService; + title = signal('app'); + constructor(titleService: TitleService) { + this.titleService = titleService; + this.title.set(this.titleService.getTitle()); + } + +static ctorParameters = () => [ + { type: TitleService } +]; +}; +App = __decorate([ + Component({ + selector: 'app-root', + imports: [RouterOutlet, Lib1], + template: __NG_CLI_RESOURCE__0, + styles: [__NG_CLI_RESOURCE__1], +}) +], App); +export { App }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_inline_template.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_inline_template.snap new file mode 100644 index 000000000..0589aaf1c --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_inline_template.snap @@ -0,0 +1,16 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { __decorate } from "tslib"; + +let AppComponent = class AppComponent {}; +AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: '

Hello

', + standalone: true, +}) +], AppComponent); +export { AppComponent }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_style_url.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_style_url.snap new file mode 100644 index 000000000..f4deebe5f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_style_url.snap @@ -0,0 +1,17 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { __decorate } from "tslib"; +import __NG_CLI_RESOURCE__0 from "angular:jit:style:file;./app.css"; + +let AppComponent = class AppComponent {}; +AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: '

Hello

', + styles: [__NG_CLI_RESOURCE__0], +}) +], AppComponent); +export { AppComponent }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_template_url.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_template_url.snap new file mode 100644 index 000000000..e484ba5c7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_template_url.snap @@ -0,0 +1,17 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { __decorate } from "tslib"; +import __NG_CLI_RESOURCE__0 from "angular:jit:template:file;./app.html"; + +let AppComponent = class AppComponent {}; +AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: __NG_CLI_RESOURCE__0, + standalone: true, +}) +], AppComponent); +export { AppComponent }; From 61b518f79c60706dcc1345894a397a46782ccb17 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 8 Mar 2026 22:54:39 +0800 Subject: [PATCH 2/3] fix: JIT transform correctly downlevels member decorators and union types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two bugs identified in code review: 1. (High) Member decorators (@Input, @Output, @HostBinding, etc.) were removed from class bodies without being emitted as `static propDecorators`. Angular's JIT runtime reads `propDecorators` to discover inputs/outputs/ queries at runtime — without it, data binding silently breaks. Now emits: static propDecorators = { myInput: [{ type: Input }], myOutput: [{ type: Output }], }; 2. (Medium) `extract_type_name_from_annotation` only skipped TSNullKeyword in union types. For `undefined | T` or `null | undefined | T`, the function encountered TSUndefinedKeyword first, immediately recursed and returned None, never reaching the actual type. Fixed to try each union member and return the first resolvable name. Co-Authored-By: Claude Opus 4.6 --- .../src/component/transform.rs | 155 +++++++++++++++++- .../tests/integration_test.rs | 105 ++++++++++++ .../integration_test__jit_directive.snap | 4 + ...integration_test__jit_prop_decorators.snap | 26 +++ ...tion_test__jit_union_type_ctor_params.snap | 24 +++ 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_prop_decorators.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 58f9c27ea..81af91a33 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -675,6 +675,8 @@ struct JitClassInfo { is_default_export: bool, /// Constructor parameter info for ctorParameters. ctor_params: std::vec::Vec, + /// Member decorator info for propDecorators. + member_decorators: std::vec::Vec, /// The modified decorator expression text for __decorate call. decorator_text: String, } @@ -695,6 +697,14 @@ struct JitParamDecorator { args: Option, } +/// A member (property/method) with its Angular decorators for propDecorators. +struct JitMemberDecorator { + /// The property/member name. + member_name: String, + /// The Angular decorators on this member. + decorators: std::vec::Vec, +} + /// Find any Angular decorator on a class and return its kind and the decorator reference. fn find_angular_decorator<'a>( class: &'a oxc_ast::ast::Class<'a>, @@ -788,6 +798,122 @@ fn extract_jit_ctor_params( params } +/// Extract Angular member decorators for JIT propDecorators generation. +/// +/// Collects all Angular-relevant decorators from class properties/methods +/// (excluding constructor) so they can be emitted as a `static propDecorators` property. +fn extract_jit_member_decorators( + source: &str, + class: &oxc_ast::ast::Class<'_>, +) -> std::vec::Vec { + use oxc_ast::ast::{ClassElement, MethodDefinitionKind, PropertyKey}; + + const ANGULAR_MEMBER_DECORATORS: &[&str] = &[ + "Input", + "Output", + "HostBinding", + "HostListener", + "ViewChild", + "ViewChildren", + "ContentChild", + "ContentChildren", + ]; + + let mut result: std::vec::Vec = std::vec::Vec::new(); + + for element in &class.body.body { + let (member_name, decorators) = match element { + ClassElement::PropertyDefinition(prop) => { + let name = match &prop.key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + (name, &prop.decorators) + } + ClassElement::MethodDefinition(method) => { + if method.kind == MethodDefinitionKind::Constructor { + continue; + } + let name = match &method.key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + (name, &method.decorators) + } + ClassElement::AccessorProperty(accessor) => { + let name = match &accessor.key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + (name, &accessor.decorators) + } + _ => continue, + }; + + let mut angular_decs: std::vec::Vec = std::vec::Vec::new(); + + for decorator in decorators { + let (dec_name, call_args) = match &decorator.expression { + Expression::CallExpression(call) => { + let name = match &call.callee { + Expression::Identifier(id) => id.name.to_string(), + Expression::StaticMemberExpression(m) => m.property.name.to_string(), + _ => continue, + }; + let args = if call.arguments.is_empty() { + None + } else { + let start = call.arguments.first().unwrap().span().start; + let end = call.arguments.last().unwrap().span().end; + Some(source[start as usize..end as usize].to_string()) + }; + (name, args) + } + Expression::Identifier(id) => (id.name.to_string(), None), + _ => continue, + }; + + if ANGULAR_MEMBER_DECORATORS.contains(&dec_name.as_str()) { + angular_decs.push(JitParamDecorator { name: dec_name, args: call_args }); + } + } + + if !angular_decs.is_empty() { + result.push(JitMemberDecorator { member_name, decorators: angular_decs }); + } + } + + result +} + +/// Build the propDecorators static property text for JIT member decorator metadata. +fn build_prop_decorators_text(members: &[JitMemberDecorator]) -> Option { + if members.is_empty() { + return None; + } + + let mut entries: std::vec::Vec = std::vec::Vec::new(); + for member in members { + let dec_strs: std::vec::Vec = member + .decorators + .iter() + .map(|d| { + if let Some(ref args) = d.args { + format!("{{ type: {}, args: [{}] }}", d.name, args) + } else { + format!("{{ type: {} }}", d.name) + } + }) + .collect(); + entries.push(format!(" {}: [{}]", member.member_name, dec_strs.join(", "))); + } + + Some(format!("static propDecorators = {{\n{}\n}}", entries.join(",\n"))) +} + /// Extract a type name from a TypeScript type annotation for JIT ctorParameters. fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>) -> Option { match type_annotation { @@ -803,10 +929,13 @@ fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>) } } oxc_ast::ast::TSType::TSUnionType(union) => { - // For union types like `T | null`, try to find the non-null type + // For union types like `T | null`, `undefined | T`, `null | undefined | T`, + // iterate all members and return the first that resolves to a name. + // Using try-each-and-continue handles TSNullKeyword, TSUndefinedKeyword, + // and any other non-reference type gracefully. for t in &union.types { - if !matches!(t, oxc_ast::ast::TSType::TSNullKeyword(_)) { - return extract_type_name_from_annotation(t); + if let Some(name) = extract_type_name_from_annotation(t) { + return Some(name); } } None @@ -1062,6 +1191,9 @@ fn transform_angular_file_jit( // Extract constructor parameters for ctorParameters let ctor_params = extract_jit_ctor_params(source, class); + // Extract member decorators for propDecorators + let member_decorators = extract_jit_member_decorators(source, class); + jit_classes.push(JitClassInfo { class_name, decorator_span: decorator.span, @@ -1071,6 +1203,7 @@ fn transform_angular_file_jit( is_exported, is_default_export, ctor_params, + member_decorators, decorator_text, }); @@ -1195,9 +1328,19 @@ fn transform_angular_file_jit( )); } - // 4d. Add ctorParameters inside class body (before closing `}`) - if let Some(ctor_text) = build_ctor_parameters_text(&jit_info.ctor_params) { - edits.push(Edit::insert(jit_info.class_body_end - 1, format!("\n{};\n", ctor_text))); + // 4d. Add ctorParameters and propDecorators inside class body (before closing `}`) + { + let mut class_statics = String::new(); + if let Some(ctor_text) = build_ctor_parameters_text(&jit_info.ctor_params) { + class_statics.push_str(&format!("\n{};", ctor_text)); + } + if let Some(prop_text) = build_prop_decorators_text(&jit_info.member_decorators) { + class_statics.push_str(&format!("\n{};", prop_text)); + } + if !class_statics.is_empty() { + class_statics.push('\n'); + edits.push(Edit::insert(jit_info.class_body_end - 1, class_statics)); + } } // 4e. After class body, add __decorate call and export diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index b1127ac14..70cf30e55 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -6245,3 +6245,108 @@ export class App { insta::assert_snapshot!("jit_full_component", result.code); } + +#[test] +fn test_jit_prop_decorators_emitted() { + // Bug fix: member decorators (@Input, @Output, etc.) must be downleveled + // to static propDecorators so Angular's JIT runtime can discover inputs/outputs. + // Without this, @Input/@Output decorators are silently lost, breaking data binding. + let allocator = Allocator::default(); + let source = r#" +import { Directive, Input, Output, HostBinding, EventEmitter } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', +}) +export class HighlightDirective { + @Input() color: string = 'yellow'; + @Input('aliasName') title: string = ''; + @Output() colorChange = new EventEmitter(); + @HostBinding('class.active') isActive = false; +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "highlight.directive.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // propDecorators must be present — Angular's JIT runtime reads this + assert!( + result.code.contains("propDecorators"), + "JIT output must emit static propDecorators. Got:\n{}", + result.code + ); + + // Each decorated member should appear in propDecorators + assert!(result.code.contains("color:"), "propDecorators should list 'color'"); + assert!(result.code.contains("title:"), "propDecorators should list 'title'"); + assert!(result.code.contains("colorChange:"), "propDecorators should list 'colorChange'"); + assert!(result.code.contains("isActive:"), "propDecorators should list 'isActive'"); + + // The decorator type references should be present + assert!(result.code.contains("type: Input"), "propDecorators should reference Input"); + assert!(result.code.contains("type: Output"), "propDecorators should reference Output"); + assert!( + result.code.contains("type: HostBinding"), + "propDecorators should reference HostBinding" + ); + + // The original decorators must be removed from the class body + assert!( + !result.code.contains("@Input()"), + "@Input decorator must be removed from class body" + ); + assert!( + !result.code.contains("@Output()"), + "@Output decorator must be removed from class body" + ); + + insta::assert_snapshot!("jit_prop_decorators", result.code); +} + +#[test] +fn test_jit_union_type_ctor_params() { + // Bug fix: union types like `undefined | SomeService` or `null | undefined | T` + // must correctly extract the type name for ctorParameters. + // Previously only TSNullKeyword was skipped, causing TSUndefinedKeyword to short-circuit. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; +import { ServiceA } from './a.service'; +import { ServiceB } from './b.service'; + +@Component({ selector: 'test', template: '' }) +export class TestComponent { + constructor( + svcA: undefined | ServiceA, + svcB: null | undefined | ServiceB, + ) {} +} +"#; + + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Both types must be correctly extracted despite union with undefined/null + assert!( + result.code.contains("type: ServiceA"), + "ctorParameters should resolve 'undefined | ServiceA' to ServiceA. Got:\n{}", + result.code + ); + assert!( + result.code.contains("type: ServiceB"), + "ctorParameters should resolve 'null | undefined | ServiceB' to ServiceB. Got:\n{}", + result.code + ); + + // Should NOT emit 'type: undefined' for either + assert!( + !result.code.contains("type: undefined"), + "ctorParameters must not emit 'type: undefined' for resolvable union types. Got:\n{}", + result.code + ); + + insta::assert_snapshot!("jit_union_type_ctor_params", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap index d47d5a0e9..7d7147027 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_directive.snap @@ -7,6 +7,10 @@ import { __decorate } from "tslib"; let HighlightDirective = class HighlightDirective { color: string = 'yellow'; + +static propDecorators = { + color: [{ type: Input }] +}; }; HighlightDirective = __decorate([ Directive({ diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_prop_decorators.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_prop_decorators.snap new file mode 100644 index 000000000..3b25eeaff --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_prop_decorators.snap @@ -0,0 +1,26 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Directive, Input, Output, HostBinding, EventEmitter } from '@angular/core'; +import { __decorate } from "tslib"; + +let HighlightDirective = class HighlightDirective { + color: string = 'yellow'; + title: string = ''; + colorChange = new EventEmitter(); + isActive = false; + +static propDecorators = { + color: [{ type: Input }], + title: [{ type: Input, args: ['aliasName'] }], + colorChange: [{ type: Output }], + isActive: [{ type: HostBinding, args: ['class.active'] }] +}; +}; +HighlightDirective = __decorate([ + Directive({ + selector: '[appHighlight]', +}) +], HighlightDirective); +export { HighlightDirective }; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap new file mode 100644 index 000000000..e7f9449d3 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap @@ -0,0 +1,24 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: result.code +--- +import { Component } from '@angular/core'; +import { ServiceA } from './a.service'; +import { ServiceB } from './b.service'; +import { __decorate } from "tslib"; + +let TestComponent = class TestComponent { + constructor( + svcA: undefined | ServiceA, + svcB: null | undefined | ServiceB, + ) {} + +static ctorParameters = () => [ + { type: ServiceA }, + { type: ServiceB } +]; +}; +TestComponent = __decorate([ + Component({ selector: 'test', template: '' }) +], TestComponent); +export { TestComponent }; From 56a1f69ae7a23d674cf65d1bd2fa1f2cb84cd1c1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 8 Mar 2026 23:06:47 +0800 Subject: [PATCH 3/3] fix(jit): align union type resolution with Angular's typeReferenceToExpression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angular's reference filters only `null` literal types from union types and requires exactly one non-null type to remain. Previously we iterated all union members and returned the first resolvable one, which is more permissive than Angular's behavior. With Angular-aligned semantics: - `T | null` → resolves to T (1 non-null type remains) - `undefined | T` → unresolvable (2 non-null types remain) - `null | undefined | T` → unresolvable (2 non-null types remain) Unresolvable types emit `{ type: undefined }` matching Angular's `paramType || ts.factory.createIdentifier('undefined')` fallback. Co-Authored-By: Claude Opus 4.6 --- .../src/component/transform.rs | 20 +++++----- .../tests/integration_test.rs | 38 +++++++++++-------- ...tion_test__jit_union_type_ctor_params.snap | 9 ++++- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 81af91a33..6c1516459 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -929,16 +929,16 @@ fn extract_type_name_from_annotation(type_annotation: &oxc_ast::ast::TSType<'_>) } } oxc_ast::ast::TSType::TSUnionType(union) => { - // For union types like `T | null`, `undefined | T`, `null | undefined | T`, - // iterate all members and return the first that resolves to a name. - // Using try-each-and-continue handles TSNullKeyword, TSUndefinedKeyword, - // and any other non-reference type gracefully. - for t in &union.types { - if let Some(name) = extract_type_name_from_annotation(t) { - return Some(name); - } - } - None + // Match Angular's typeReferenceToExpression behavior: + // filter out only `null` literal types, and if exactly one type remains, + // resolve that type. Otherwise, return None (unresolvable). + // See: angular/packages/compiler-cli/src/ngtsc/transform/jit/src/downlevel_decorators_transform.ts + let non_null: std::vec::Vec<_> = union + .types + .iter() + .filter(|t| !matches!(t, oxc_ast::ast::TSType::TSNullKeyword(_))) + .collect(); + if non_null.len() == 1 { extract_type_name_from_annotation(non_null[0]) } else { None } } _ => None, } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 70cf30e55..c13568deb 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -6293,10 +6293,7 @@ export class HighlightDirective { ); // The original decorators must be removed from the class body - assert!( - !result.code.contains("@Input()"), - "@Input decorator must be removed from class body" - ); + assert!(!result.code.contains("@Input()"), "@Input decorator must be removed from class body"); assert!( !result.code.contains("@Output()"), "@Output decorator must be removed from class body" @@ -6307,20 +6304,28 @@ export class HighlightDirective { #[test] fn test_jit_union_type_ctor_params() { - // Bug fix: union types like `undefined | SomeService` or `null | undefined | T` - // must correctly extract the type name for ctorParameters. - // Previously only TSNullKeyword was skipped, causing TSUndefinedKeyword to short-circuit. + // Angular-aligned union type behavior for ctorParameters. + // Angular's typeReferenceToExpression filters ONLY `null` literal types. + // If exactly one non-null type remains, it resolves; otherwise unresolvable. + // + // `T | null` → resolves to T (1 non-null type) + // `undefined | T` → unresolvable (2 non-null types: undefined + T) + // `null | undefined | T` → unresolvable (2 non-null types: undefined + T) + // + // See: angular/packages/compiler-cli/src/ngtsc/transform/jit/src/downlevel_decorators_transform.ts let allocator = Allocator::default(); let source = r#" import { Component } from '@angular/core'; import { ServiceA } from './a.service'; import { ServiceB } from './b.service'; +import { ServiceC } from './c.service'; @Component({ selector: 'test', template: '' }) export class TestComponent { constructor( svcA: undefined | ServiceA, svcB: null | undefined | ServiceB, + svcC: ServiceC | null, ) {} } "#; @@ -6329,22 +6334,23 @@ export class TestComponent { let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); - // Both types must be correctly extracted despite union with undefined/null + // `ServiceC | null` resolves correctly (1 non-null type) assert!( - result.code.contains("type: ServiceA"), - "ctorParameters should resolve 'undefined | ServiceA' to ServiceA. Got:\n{}", + result.code.contains("type: ServiceC"), + "ctorParameters should resolve 'ServiceC | null' to ServiceC. Got:\n{}", result.code ); + + // `undefined | ServiceA` and `null | undefined | ServiceB` are unresolvable per Angular spec + // (2 non-null types remain after filtering null) assert!( - result.code.contains("type: ServiceB"), - "ctorParameters should resolve 'null | undefined | ServiceB' to ServiceB. Got:\n{}", + !result.code.contains("type: ServiceA"), + "ctorParameters must not resolve 'undefined | ServiceA' (2 non-null types). Got:\n{}", result.code ); - - // Should NOT emit 'type: undefined' for either assert!( - !result.code.contains("type: undefined"), - "ctorParameters must not emit 'type: undefined' for resolvable union types. Got:\n{}", + !result.code.contains("type: ServiceB"), + "ctorParameters must not resolve 'null | undefined | ServiceB' (2 non-null types). Got:\n{}", result.code ); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap index e7f9449d3..7b1d8cc21 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_union_type_ctor_params.snap @@ -1,21 +1,26 @@ --- source: crates/oxc_angular_compiler/tests/integration_test.rs +assertion_line: 6360 expression: result.code --- + import { Component } from '@angular/core'; import { ServiceA } from './a.service'; import { ServiceB } from './b.service'; +import { ServiceC } from './c.service'; import { __decorate } from "tslib"; let TestComponent = class TestComponent { constructor( svcA: undefined | ServiceA, svcB: null | undefined | ServiceB, + svcC: ServiceC | null, ) {} static ctorParameters = () => [ - { type: ServiceA }, - { type: ServiceB } + { type: undefined }, + { type: undefined }, + { type: ServiceC } ]; }; TestComponent = __decorate([