diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 58fe52f26..9d810baed 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -931,11 +931,6 @@ fn extract_param_dependency<'a>( // Determine the token: // 1. If @Inject(TOKEN) is present, use TOKEN // 2. Otherwise, use the type annotation - // - // Track whether the token comes from @Inject decorator (value) or type annotation. - // This affects import reuse: @Inject tokens can reuse named imports, but type - // annotation tokens need namespace imports because TypeScript types are erased. - let token_from_inject = inject_token.is_some(); let token = inject_token.or_else(|| extract_param_token(param)); // Handle @Attribute decorator @@ -950,12 +945,10 @@ fn extract_param_dependency<'a>( // Look up the token in the import map to find its source module and import type if let Some(import_info) = import_map.get(token_name) { d.token_source_module = Some(import_info.source_module.clone()); - // Only reuse named imports for tokens from @Inject decorator. - // Type annotation tokens need namespace imports because TypeScript - // types are erased at runtime and may not be available as values. - // This matches Angular's behavior: @Inject(TOKEN) uses bare TOKEN, - // but `param: ServiceType` uses `i1.ServiceType`. - d.has_named_import = token_from_inject && import_info.is_named_import; + // Always use namespace imports for DI tokens (has_named_import = false). + // Import elision removes @Inject(TOKEN) argument imports since they're + // only used in decorator positions that get compiled away. + // Using bare TOKEN would fail at runtime because the import is gone. } d } diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 95472b5b3..926897a9c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -44,7 +44,8 @@ use crate::ng_module::{ extract_ng_module_metadata, find_ng_module_decorator_span, generate_full_ng_module_definition, }; use crate::output::ast::{ - DeclareFunctionStmt, FunctionExpr, OutputStatement, ReadVarExpr, StmtModifier, + DeclareFunctionStmt, FunctionExpr, OutputExpression, OutputStatement, ReadPropExpr, + ReadVarExpr, StmtModifier, }; use crate::output::emitter::JsEmitter; use crate::parser::ParseTemplateOptions; @@ -516,6 +517,48 @@ pub fn build_import_map<'a>( /// Find the byte position (in the source) just after the last import statement. /// +/// Resolve namespace imports for factory dependency tokens. +/// +/// The import elision phase removes type-only imports (e.g., `import { Store } from '@ngrx/store'`) +/// because constructor parameter types are considered type-only. However, the factory function +/// needs to reference these types at runtime (e.g., `i0.ɵɵinject(Store)`). +/// +/// This function replaces bare `ReadVar` tokens with namespace-prefixed `ReadProp` references +/// (e.g., `Store` → `i1.Store`) for any token that has a corresponding import in the import map. +/// This ensures the factory works correctly even after import elision. +fn resolve_factory_dep_namespaces<'a>( + allocator: &'a Allocator, + deps: &mut oxc_allocator::Vec<'a, crate::factory::R3DependencyMetadata<'a>>, + import_map: &ImportMap<'a>, + namespace_registry: &mut NamespaceRegistry<'a>, +) { + for dep in deps.iter_mut() { + let Some(ref token) = dep.token else { continue }; + // Only process bare variable references (ReadVar) + let OutputExpression::ReadVar(var) = token else { continue }; + let name = &var.name; + // Look up this identifier in the import map + let Some(import_info) = import_map.get(name) else { continue }; + // Replace with namespace-prefixed reference: i1.Store instead of Store + let namespace = namespace_registry.get_or_assign(&import_info.source_module); + dep.token = Some(OutputExpression::ReadProp(oxc_allocator::Box::new_in( + ReadPropExpr { + receiver: oxc_allocator::Box::new_in( + OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: namespace, source_span: None }, + allocator, + )), + allocator, + ), + name: name.clone(), + optional: false, + source_span: None, + }, + allocator, + ))); + } +} + /// This is used to determine where to insert namespace imports so they appear /// AFTER existing imports but BEFORE other code (like class declarations). /// @@ -881,7 +924,7 @@ pub fn transform_angular_file( // definitions. This prevents Angular's JIT runtime from processing // the directive and creating conflicting property definitions (like // ɵfac getters) that interfere with the AOT-compiled assignments. - if let Some(directive_metadata) = + if let Some(mut directive_metadata) = extract_directive_metadata(allocator, class, implicit_standalone) { // Track decorator span for removal @@ -893,6 +936,18 @@ pub fn transform_angular_file( // Collect member decorators (@Input, @Output, @HostBinding, etc.) collect_member_decorator_spans(class, &mut decorator_spans_to_remove); + // Resolve namespace imports for directive constructor deps. + // Directives can inject services from other modules (e.g., Store from @ngrx/store), + // so factory deps must use namespace-prefixed references (e.g., i1.Store). + if let Some(ref mut deps) = directive_metadata.deps { + resolve_factory_dep_namespaces( + allocator, + deps, + &import_map, + &mut file_namespace_registry, + ); + } + // Compile directive and generate definitions // Pass shared_pool_index to ensure unique constant names across the file let definitions = generate_directive_definitions( @@ -918,8 +973,7 @@ pub fn transform_angular_file( // Track definitions by class name (position is recalculated later) class_definitions .insert(class_name, (property_assignments, String::new(), String::new())); - // Directive only needs @angular/core, which is already pre-registered - } else if let Some(injectable_metadata) = + } else if let Some(mut injectable_metadata) = extract_injectable_metadata(allocator, class) { // Not a @Component or @Directive - check if it's an @Injectable @@ -934,6 +988,19 @@ pub fn transform_angular_file( // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + // Resolve namespace imports for constructor deps. + // The import elision removes type-only imports (e.g., `import { Store } from '@ngrx/store'`), + // so factory deps must use namespace-prefixed references (e.g., `i1.Store`) + // instead of bare identifiers. + if let Some(ref mut deps) = injectable_metadata.deps { + resolve_factory_dep_namespaces( + allocator, + deps, + &import_map, + &mut file_namespace_registry, + ); + } + // Compile injectable and generate definitions if let Some(definition) = generate_injectable_definition_from_decorator( allocator, @@ -960,7 +1027,7 @@ pub fn transform_angular_file( ); // Injectable only needs @angular/core, which is already pre-registered } - } else if let Some(pipe_metadata) = + } else if let Some(mut pipe_metadata) = extract_pipe_metadata(allocator, class, implicit_standalone) { // Not a @Component, @Directive, or @Injectable - check if it's a @Pipe @@ -975,6 +1042,16 @@ pub fn transform_angular_file( // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + // Resolve namespace imports for pipe constructor deps + if let Some(ref mut deps) = pipe_metadata.deps { + resolve_factory_dep_namespaces( + allocator, + deps, + &import_map, + &mut file_namespace_registry, + ); + } + // Compile pipe and generate both ɵfac and ɵpipe definitions as external property assignments if let Some(definition) = generate_full_pipe_definition_from_decorator(allocator, &pipe_metadata) @@ -997,7 +1074,7 @@ pub fn transform_angular_file( ); // Pipe only needs @angular/core, which is already pre-registered } - } else if let Some(ng_module_metadata) = + } else if let Some(mut ng_module_metadata) = extract_ng_module_metadata(allocator, class) { // Not a @Component, @Directive, @Injectable, or @Pipe - check if it's an @NgModule @@ -1013,6 +1090,16 @@ pub fn transform_angular_file( // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + // Resolve namespace imports for NgModule constructor deps + if let Some(ref mut deps) = ng_module_metadata.deps { + resolve_factory_dep_namespaces( + allocator, + deps, + &import_map, + &mut file_namespace_registry, + ); + } + // Compile NgModule and generate all definitions as external property assignments if let Some(definition) = generate_full_ng_module_definition(allocator, &ng_module_metadata) @@ -1040,10 +1127,10 @@ pub fn transform_angular_file( } // Track definitions by class name (position is recalculated later) - // NgModule: external_decls go BEFORE the class (they're statements, not class refs) + // NgModule: external_decls go AFTER the class (they reference the class name) class_definitions.insert( class_name, - (property_assignments, external_decls, String::new()), + (property_assignments, String::new(), external_decls), ); // NgModule only needs @angular/core, which is already pre-registered } @@ -2035,19 +2122,54 @@ pub fn compile_template_for_hmr<'a>( // constant references match. Without this, the HMR module would spread the old ɵcmp // which has a different consts array, causing index out of bounds errors. let consts_js = if !job.consts.is_empty() { - use crate::output::ast::{LiteralArrayExpr, OutputExpression}; + use crate::output::ast::{ + FunctionExpr, LiteralArrayExpr, OutputExpression, OutputStatement, ReturnStatement, + }; let mut const_entries: OxcVec<'a, OutputExpression<'a>> = OxcVec::new_in(allocator); for const_value in &job.consts { const_entries.push(const_value_to_expression(allocator, const_value)); } - let consts_array = OutputExpression::LiteralArray(oxc_allocator::Box::new_in( - LiteralArrayExpr { entries: const_entries, source_span: None }, - allocator, - )); + let consts_expr = if !job.consts_initializers.is_empty() { + // When there are initializers (e.g., i18n variable declarations), wrap consts + // in a function that runs initializers first and returns the array. + // This matches what definition.rs does for the initial component definition. + let mut fn_stmts: OxcVec<'a, OutputStatement<'a>> = + OxcVec::with_capacity_in(job.consts_initializers.len() + 1, allocator); + + for stmt in job.consts_initializers.drain(..) { + fn_stmts.push(stmt); + } - Some(emitter.emit_expression(&consts_array)) + fn_stmts.push(OutputStatement::Return(oxc_allocator::Box::new_in( + ReturnStatement { + value: OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + LiteralArrayExpr { entries: const_entries, source_span: None }, + allocator, + )), + source_span: None, + }, + allocator, + ))); + + OutputExpression::Function(oxc_allocator::Box::new_in( + FunctionExpr { + name: None, + params: OxcVec::new_in(allocator), + statements: fn_stmts, + source_span: None, + }, + allocator, + )) + } else { + OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + LiteralArrayExpr { entries: const_entries, source_span: None }, + allocator, + )) + }; + + Some(emitter.emit_expression(&consts_expr)) } else { None }; @@ -4055,17 +4177,29 @@ export class TestComponent { let result = transform_angular_file(&allocator, "test.component.ts", source, &options, None); - // Should generate namespace import for rxjs (i1 because i0=@angular/core; - // DARK_THEME uses bare name via @Inject so @app/theme doesn't get a namespace) + // @Inject(DARK_THEME) now uses namespace imports (i1 for @app/theme), + // so rxjs gets i2 for Observable. assert!( - result.code.contains("import * as i1 from 'rxjs'"), + result.code.contains("import * as i1 from '@app/theme'"), + "Should generate namespace import for @app/theme, but got:\n{}", + result.code + ); + assert!( + result.code.contains("import * as i2 from 'rxjs'"), "Should generate namespace import for rxjs, but got:\n{}", result.code ); - // The setClassMetadata ctor params should reference i1.Observable + // The factory should use namespace-prefixed DARK_THEME assert!( - result.code.contains("i1.Observable"), + result.code.contains("i1.DARK_THEME"), + "Factory should use namespace-prefixed DARK_THEME, but got:\n{}", + result.code + ); + + // The setClassMetadata ctor params should reference i2.Observable + assert!( + result.code.contains("i2.Observable"), "setClassMetadata should use namespace-prefixed Observable, but got:\n{}", result.code ); @@ -4193,4 +4327,73 @@ export class TestComponent { result.code ); } + + #[test] + fn test_directive_factory_deps_get_correct_namespace_resolution() { + // Regression test for bug where resolve_factory_dep_namespaces() was NOT called + // for @Directive constructor deps. This caused bare ReadVar references (e.g., Store) + // to remain unresolved, resulting in incorrect namespace prefixes at runtime + // (e.g., i0.Store instead of the correct i1.Store). + // + // The fix: Added resolve_factory_dep_namespaces() call for directive deps in + // the directive processing path of transform_angular_file(). + let allocator = Allocator::default(); + let source = r#" +import { Directive } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { SomeService } from '@app/services'; + +@Directive({ selector: '[myDir]' }) +export class MyDirective { + constructor(private store: Store, private svc: SomeService) {} +} +"#; + + let result = transform_angular_file( + &allocator, + "my.directive.ts", + source, + &TransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Transform should not have errors: {:?}", result.diagnostics); + + // Verify namespace imports are generated for the external modules + assert!( + result.code.contains("import * as i1 from '@ngrx/store'"), + "Should generate namespace import for @ngrx/store, but got:\n{}", + result.code + ); + assert!( + result.code.contains("import * as i2 from '@app/services'"), + "Should generate namespace import for @app/services, but got:\n{}", + result.code + ); + + // Verify the factory uses the correct namespace prefixes for deps + // Store should be i1.Store (from @ngrx/store), NOT i0.Store + assert!( + result.code.contains("i1.Store"), + "Factory should reference Store as i1.Store (from @ngrx/store), but got:\n{}", + result.code + ); + assert!( + !result.code.contains("i0.Store"), + "Factory should NOT reference Store as i0.Store (that's @angular/core), but got:\n{}", + result.code + ); + + // SomeService should be i2.SomeService (from @app/services), NOT i0.SomeService + assert!( + result.code.contains("i2.SomeService"), + "Factory should reference SomeService as i2.SomeService (from @app/services), but got:\n{}", + result.code + ); + assert!( + !result.code.contains("i0.SomeService"), + "Factory should NOT reference SomeService as i0.SomeService (that's @angular/core), but got:\n{}", + result.code + ); + } } diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 20d7986b0..aad424a83 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -372,12 +372,10 @@ fn get_decorator_name_from_expr<'a>(expr: &'a Expression<'a>) -> Option /// Extract the injection token from a parameter's type annotation. /// -/// Type annotations are erased at runtime by TypeScript, so we need to generate -/// namespace-prefixed property access (e.g., `i0.TemplateRef`) to ensure the -/// runtime value is available. -/// -/// Note: Unlike @Inject tokens which use named imports, type annotations always -/// need the namespace prefix because the type may not be available as a value. +/// Returns a bare `ReadVar` expression with the type name. The caller +/// (`resolve_factory_dep_namespaces` in `transform.rs`) is responsible for +/// looking up the correct namespace based on the import map and converting +/// it to a namespace-prefixed `ReadProp` (e.g., `i1.Store`). fn extract_param_token<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, @@ -398,27 +396,8 @@ fn extract_param_token<'a>( } }; - // Type annotations need to be accessed through the namespace import (i0.TypeName) - // because TypeScript erases types at runtime. The namespace ensures the runtime - // value is available. - // - // For example: `constructor(template: TemplateRef)` should generate - // `i0.ɵɵdirectiveInject(i0.TemplateRef)` not `i0.ɵɵdirectiveInject(TemplateRef)` - // - // This matches Angular's behavior for type-annotation-based injection. - return Some(OutputExpression::ReadProp(Box::new_in( - crate::output::ast::ReadPropExpr { - receiver: Box::new_in( - OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: Atom::from("i0"), source_span: None }, - allocator, - )), - allocator, - ), - name: type_name, - optional: false, - source_span: None, - }, + return Some(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: type_name, source_span: None }, allocator, ))); } @@ -1399,4 +1378,54 @@ mod tests { assert!(meta.uses_inheritance, "Should have inheritance"); }); } + + #[test] + fn test_extract_param_token_returns_read_var_not_read_prop() { + // Regression test for bug where extract_param_token() returned + // ReadProp(i0.TypeName) with hardcoded i0, instead of a bare + // ReadVar(TypeName). The ReadProp prevented resolve_factory_dep_namespaces() + // from processing the tokens (it only handles ReadVar tokens), causing + // all directive constructor deps to be assigned the wrong namespace. + // + // The fix: Changed extract_param_token to return ReadVar(TypeName) + // matching the pattern used by injectable, pipe, and ng_module extractors. + let code = r#" + @Directive({ selector: '[myDir]' }) + class MyDirective { + constructor(private store: Store, private svc: SomeService) {} + } + "#; + assert_directive_metadata(code, |meta| { + // Should have 2 constructor deps + let deps = meta.deps.as_ref().expect("Directive should have deps"); + assert_eq!(deps.len(), 2, "Should have 2 constructor deps"); + + // Each dep token should be a ReadVar (bare identifier), NOT a ReadProp + // ReadVar tokens can be resolved by resolve_factory_dep_namespaces() + // to the correct namespace prefix (e.g., i1.Store instead of i0.Store) + for (i, dep) in deps.iter().enumerate() { + let token = dep.token.as_ref().unwrap_or_else(|| { + panic!("Dep {} should have a token", i); + }); + assert!( + matches!(token, crate::output::ast::OutputExpression::ReadVar(_)), + "Dep {} token should be ReadVar (bare identifier), but got ReadProp or other. \ + This means resolve_factory_dep_namespaces() cannot process it.", + i + ); + } + + // Verify the specific token names + if let crate::output::ast::OutputExpression::ReadVar(var) = + deps[0].token.as_ref().unwrap() + { + assert_eq!(var.name.as_str(), "Store", "First dep should be Store"); + } + if let crate::output::ast::OutputExpression::ReadVar(var) = + deps[1].token.as_ref().unwrap() + { + assert_eq!(var.name.as_str(), "SomeService", "Second dep should be SomeService"); + } + }); + } } diff --git a/crates/oxc_angular_compiler/src/directive/query.rs b/crates/oxc_angular_compiler/src/directive/query.rs index 29c928a1f..afa7f9c75 100644 --- a/crates/oxc_angular_compiler/src/directive/query.rs +++ b/crates/oxc_angular_compiler/src/directive/query.rs @@ -460,10 +460,6 @@ pub fn create_view_queries_function<'a>( let mut update_statements: Vec<'a, MaybeAdvanceStatement<'a>> = Vec::new_in(allocator); let mut temp_allocator = TempAllocator::new(); - // Track chained calls - let mut view_query_signal_call: Option> = None; - let mut view_query_call: Option> = None; - for (idx, query) in view_queries.iter().enumerate() { // Creation: ɵɵviewQuery(predicate, flags, read) or ɵɵviewQuerySignal(ctx.prop, predicate, flags, read) // Use pre-pooled predicate instead of calling get_query_create_parameters @@ -473,24 +469,15 @@ pub fn create_view_queries_function<'a>( pooled_predicates[idx].clone_in(allocator), ); + // Emit each query as a separate statement. + // Angular 20's ɵɵviewQuery returns void, so chaining is not supported. if query.is_signal { - // Angular's pattern: viewQuerySignalCall ??= o.importExpr(R3.viewQuerySignal); - // viewQuerySignalCall = viewQuerySignalCall.callFn(params); - // This chains calls directly: prev(params), not prev.viewQuerySignal(params) - let fn_expr = match view_query_signal_call { - Some(prev) => prev, - None => import_expr(allocator, Identifiers::VIEW_QUERY_SIGNAL), - }; - view_query_signal_call = Some(call_fn(allocator, fn_expr, params)); + let call = + call_fn(allocator, import_expr(allocator, Identifiers::VIEW_QUERY_SIGNAL), params); + create_statements.push(expr_stmt(allocator, call)); } else { - // Angular's pattern: viewQueryCall ??= o.importExpr(R3.viewQuery); - // viewQueryCall = viewQueryCall.callFn(params); - // This chains calls directly: prev(params), not prev.viewQuery(params) - let fn_expr = match view_query_call { - Some(prev) => prev, - None => import_expr(allocator, Identifiers::VIEW_QUERY), - }; - view_query_call = Some(call_fn(allocator, fn_expr, params)); + let call = call_fn(allocator, import_expr(allocator, Identifiers::VIEW_QUERY), params); + create_statements.push(expr_stmt(allocator, call)); } // Update phase @@ -568,14 +555,6 @@ pub fn create_view_queries_function<'a>( } } - // Build create statements - if let Some(signal_call) = view_query_signal_call { - create_statements.push(expr_stmt(allocator, signal_call)); - } - if let Some(query_call) = view_query_call { - create_statements.push(expr_stmt(allocator, query_call)); - } - // Build update statements with temp variable declarations let mut final_update_statements = Vec::new_in(allocator); @@ -665,10 +644,6 @@ pub fn create_content_queries_function<'a>( let mut update_statements: Vec<'a, MaybeAdvanceStatement<'a>> = Vec::new_in(allocator); let mut temp_allocator = TempAllocator::new(); - // Track chained calls - let mut content_query_signal_call: Option> = None; - let mut content_query_call: Option> = None; - for (idx, query) in queries.iter().enumerate() { // Prepend dirIndex parameter for content queries let mut prepend = Vec::new_in(allocator); @@ -681,24 +656,19 @@ pub fn create_content_queries_function<'a>( prepend, ); + // Emit each query as a separate statement. + // Angular 20's ɵɵcontentQuery returns void, so chaining is not supported. if query.is_signal { - // Angular's pattern: contentQuerySignalCall ??= o.importExpr(R3.contentQuerySignal); - // contentQuerySignalCall = contentQuerySignalCall.callFn(params); - // This chains calls directly: prev(params), not prev.contentQuerySignal(params) - let fn_expr = match content_query_signal_call { - Some(prev) => prev, - None => import_expr(allocator, Identifiers::CONTENT_QUERY_SIGNAL), - }; - content_query_signal_call = Some(call_fn(allocator, fn_expr, params)); + let call = call_fn( + allocator, + import_expr(allocator, Identifiers::CONTENT_QUERY_SIGNAL), + params, + ); + create_statements.push(expr_stmt(allocator, call)); } else { - // Angular's pattern: contentQueryCall ??= o.importExpr(R3.contentQuery); - // contentQueryCall = contentQueryCall.callFn(params); - // This chains calls directly: prev(params), not prev.contentQuery(params) - let fn_expr = match content_query_call { - Some(prev) => prev, - None => import_expr(allocator, Identifiers::CONTENT_QUERY), - }; - content_query_call = Some(call_fn(allocator, fn_expr, params)); + let call = + call_fn(allocator, import_expr(allocator, Identifiers::CONTENT_QUERY), params); + create_statements.push(expr_stmt(allocator, call)); } // Update phase (same as view queries) @@ -769,14 +739,6 @@ pub fn create_content_queries_function<'a>( } } - // Build create statements - if let Some(signal_call) = content_query_signal_call { - create_statements.push(expr_stmt(allocator, signal_call)); - } - if let Some(query_call) = content_query_call { - create_statements.push(expr_stmt(allocator, query_call)); - } - // Build update statements with temp variable declarations let mut final_update_statements = Vec::new_in(allocator); @@ -1003,14 +965,148 @@ mod tests { println!("Chained signal queries output:\n{}", output); - // Angular chains signal queries: fn(params1)(params2) - // Each params should have: target, predicate, flags - // Remove whitespace for comparison since emitter may format differently + // Each signal query should be emitted as a separate statement. + // Angular 20's ɵɵviewQuerySignal returns void, so chaining is not supported. + let normalized = output.replace(['\n', ' '], ""); + assert!( + normalized.contains("viewQuerySignal(ctx.query1,Component1,1);") + && normalized.contains("viewQuerySignal(ctx.query2,Component2,1);"), + "Each signal query should be a separate statement.\nGot:\n{}", + output + ); + } + + /// Regression test: Multiple non-signal view queries must be separate statements. + /// + /// Previously, multiple view queries were chained as ɵɵviewQuery(p1)(p2), calling + /// the result of the first query as a function. Angular 20's ɵɵviewQuery returns void, + /// so chaining breaks with: TypeError: ɵɵviewQuery(...) is not a function. + /// + /// The fix: Emit each query as a separate statement. + #[test] + fn test_multiple_non_signal_view_queries_are_separate_statements() { + let allocator = Allocator::default(); + + let query1 = R3QueryMetadata { + property_name: Atom::from("myChild"), + first: true, + predicate: QueryPredicate::Type(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Atom::from("ChildComponent"), source_span: None }, + &allocator, + ))), + descendants: true, + emit_distinct_changes_only: true, + is_static: false, + is_signal: false, + read: None, + }; + + let query2 = R3QueryMetadata { + property_name: Atom::from("myOther"), + first: false, + predicate: QueryPredicate::Type(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Atom::from("OtherComponent"), source_span: None }, + &allocator, + ))), + descendants: true, + emit_distinct_changes_only: true, + is_static: false, + is_signal: false, + read: None, + }; + + let queries = [query1, query2]; + let result = + create_view_queries_function(&allocator, &queries, Some("TestComponent"), None); + + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&result); + let normalized = output.replace(['\n', ' '], ""); + + // Each non-signal view query should be a separate statement (ending with ;), + // NOT chained as ɵɵviewQuery(ChildComponent,5)(OtherComponent,5). + assert!( + normalized.contains("i0.ɵɵviewQuery(ChildComponent,5);"), + "First view query should be a separate statement.\nGot:\n{}", + output + ); + assert!( + normalized.contains("i0.ɵɵviewQuery(OtherComponent,5);"), + "Second view query should be a separate statement.\nGot:\n{}", + output + ); + + // Make sure they're NOT chained (the old buggy pattern) + assert!( + !normalized.contains("viewQuery(ChildComponent,5)(OtherComponent"), + "View queries must NOT be chained (Angular 20 returns void).\nGot:\n{}", + output + ); + } + + /// Regression test: Multiple content queries must be separate statements. + /// + /// Same as the view query chaining bug, but for content queries. + /// Angular 20's ɵɵcontentQuery also returns void, so chaining breaks. + #[test] + fn test_multiple_content_queries_are_separate_statements() { + let allocator = Allocator::default(); + + let query1 = R3QueryMetadata { + property_name: Atom::from("items"), + first: false, + predicate: QueryPredicate::Type(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Atom::from("ItemComponent"), source_span: None }, + &allocator, + ))), + descendants: true, + emit_distinct_changes_only: true, + is_static: false, + is_signal: false, + read: None, + }; + + let query2 = R3QueryMetadata { + property_name: Atom::from("headers"), + first: true, + predicate: QueryPredicate::Type(OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: Atom::from("HeaderComponent"), source_span: None }, + &allocator, + ))), + descendants: false, + emit_distinct_changes_only: true, + is_static: false, + is_signal: false, + read: None, + }; + + let queries = [query1, query2]; + let result = + create_content_queries_function(&allocator, &queries, Some("TestDirective"), None); + + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&result); + + let normalized = output.replace(['\n', ' '], ""); + + // Each content query should be a separate statement (ending with ;), + // NOT chained as ɵɵcontentQuery(dirIndex,ItemComponent,5)(dirIndex,HeaderComponent,4). + assert!( + normalized.contains("i0.ɵɵcontentQuery(dirIndex,ItemComponent,5);"), + "First content query should be a separate statement.\nGot:\n{}", + output + ); + assert!( + normalized.contains("i0.ɵɵcontentQuery(dirIndex,HeaderComponent,4);"), + "Second content query should be a separate statement.\nGot:\n{}", + output + ); + + // Make sure they're NOT chained assert!( - normalized - .contains("viewQuerySignal(ctx.query1,Component1,1)(ctx.query2,Component2,1)"), - "Chained signal queries should each have: target, predicate, flags.\nGot:\n{}", + !normalized.contains("contentQuery(dirIndex,ItemComponent,5)(dirIndex,HeaderComponent"), + "Content queries must NOT be chained (Angular 20 returns void).\nGot:\n{}", output ); } diff --git a/crates/oxc_angular_compiler/src/injector/compiler.rs b/crates/oxc_angular_compiler/src/injector/compiler.rs index 0efc6f66d..85bc6e7d1 100644 --- a/crates/oxc_angular_compiler/src/injector/compiler.rs +++ b/crates/oxc_angular_compiler/src/injector/compiler.rs @@ -72,17 +72,23 @@ fn build_definition_map<'a>( // imports: [...] (only if non-empty) if metadata.has_imports() { - let mut imports_items = Vec::new_in(allocator); - for import in &metadata.imports { - imports_items.push(import.clone_in(allocator)); - } + // Prefer raw_imports (preserves call expressions like StoreModule.forRoot(...)) + let imports_value = if let Some(raw_imports) = &metadata.raw_imports { + raw_imports.clone_in(allocator) + } else { + let mut imports_items = Vec::new_in(allocator); + for import in &metadata.imports { + imports_items.push(import.clone_in(allocator)); + } + OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: imports_items, source_span: None }, + allocator, + )) + }; entries.push(LiteralMapEntry { key: Atom::from("imports"), - value: OutputExpression::LiteralArray(Box::new_in( - LiteralArrayExpr { entries: imports_items, source_span: None }, - allocator, - )), + value: imports_value, quoted: false, }); } diff --git a/crates/oxc_angular_compiler/src/injector/metadata.rs b/crates/oxc_angular_compiler/src/injector/metadata.rs index e894b63c9..461252eda 100644 --- a/crates/oxc_angular_compiler/src/injector/metadata.rs +++ b/crates/oxc_angular_compiler/src/injector/metadata.rs @@ -23,8 +23,13 @@ pub struct R3InjectorMetadata<'a> { /// Can be None if no providers are defined. pub providers: Option>, - /// Imported modules/injectors. + /// Imported modules/injectors (individual expressions). pub imports: Vec<'a, OutputExpression<'a>>, + + /// Pre-built raw imports array expression. + /// When present, takes precedence over `imports` in the generated output. + /// This preserves call expressions like `StoreModule.forRoot(...)` and spread elements. + pub raw_imports: Option>, } impl<'a> R3InjectorMetadata<'a> { @@ -35,7 +40,7 @@ impl<'a> R3InjectorMetadata<'a> { /// Check if this injector has any imports. pub fn has_imports(&self) -> bool { - !self.imports.is_empty() + self.raw_imports.is_some() || !self.imports.is_empty() } } @@ -45,12 +50,19 @@ pub struct R3InjectorMetadataBuilder<'a> { r#type: Option>, providers: Option>, imports: Vec<'a, OutputExpression<'a>>, + raw_imports: Option>, } impl<'a> R3InjectorMetadataBuilder<'a> { /// Create a new builder. pub fn new(allocator: &'a oxc_allocator::Allocator) -> Self { - Self { name: None, r#type: None, providers: None, imports: Vec::new_in(allocator) } + Self { + name: None, + r#type: None, + providers: None, + imports: Vec::new_in(allocator), + raw_imports: None, + } } /// Set the injector name. @@ -77,6 +89,12 @@ impl<'a> R3InjectorMetadataBuilder<'a> { self } + /// Set raw imports array expression (takes precedence over individual imports). + pub fn raw_imports(mut self, raw_imports: OutputExpression<'a>) -> Self { + self.raw_imports = Some(raw_imports); + self + } + /// Build the metadata. /// /// Returns None if required fields (name, type) are missing. @@ -84,6 +102,12 @@ impl<'a> R3InjectorMetadataBuilder<'a> { let name = self.name?; let r#type = self.r#type?; - Some(R3InjectorMetadata { name, r#type, providers: self.providers, imports: self.imports }) + Some(R3InjectorMetadata { + name, + r#type, + providers: self.providers, + imports: self.imports, + raw_imports: self.raw_imports, + }) } } diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index eeed4bfda..3e58b3e14 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -37,6 +37,7 @@ pub mod i18n; pub mod injectable; pub mod injector; pub mod ir; +pub mod linker; pub mod ng_module; pub mod optimizer; pub mod output; @@ -134,5 +135,8 @@ pub use class_metadata::{ compile_opaque_async_class_metadata, }; +// Re-export linker types +pub use linker::{LinkResult, link}; + // Re-export optimizer types pub use optimizer::{OptimizeOptions, OptimizeResult, optimize}; diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs new file mode 100644 index 000000000..85b7b0fc4 --- /dev/null +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -0,0 +1,1077 @@ +//! Angular Partial Declaration Linker. +//! +//! Processes pre-compiled Angular library code from node_modules that contains +//! partial compilation declarations (`ɵɵngDeclare*`). These declarations need +//! to be "linked" (converted to full `ɵɵdefine*` calls) at build time. +//! +//! Without this linker, Angular falls back to JIT compilation which requires +//! `@angular/compiler` at runtime. +//! +//! ## Supported Declarations +//! +//! | Partial Declaration | Linked Output | +//! |--------------------|-| +//! | `ɵɵngDeclareFactory` | Factory function | +//! | `ɵɵngDeclareInjectable` | `ɵɵdefineInjectable(...)` | +//! | `ɵɵngDeclareInjector` | `ɵɵdefineInjector(...)` | +//! | `ɵɵngDeclareNgModule` | `ɵɵdefineNgModule(...)` | +//! | `ɵɵngDeclarePipe` | `ɵɵdefinePipe(...)` | +//! | `ɵɵngDeclareDirective` | `ɵɵdefineDirective(...)` | +//! | `ɵɵngDeclareComponent` | `ɵɵdefineComponent(...)` | +//! | `ɵɵngDeclareClassMetadata` | `ɵɵsetClassMetadata(...)` | +//! +//! ## Usage +//! +//! ```ignore +//! use oxc_allocator::Allocator; +//! use oxc_angular_compiler::linker::link; +//! +//! let allocator = Allocator::default(); +//! let code = "static ɵfac = i0.ɵɵngDeclareFactory({...});"; +//! let result = link(&allocator, code, "common.mjs"); +//! println!("{}", result.code); +//! ``` + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, ObjectExpression, ObjectPropertyKind, Program, + PropertyKey, Statement, +}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; + +use crate::optimizer::Edit; + +/// Partial declaration function names to link. +const DECLARE_FACTORY: &str = "\u{0275}\u{0275}ngDeclareFactory"; +const DECLARE_INJECTABLE: &str = "\u{0275}\u{0275}ngDeclareInjectable"; +const DECLARE_INJECTOR: &str = "\u{0275}\u{0275}ngDeclareInjector"; +const DECLARE_NG_MODULE: &str = "\u{0275}\u{0275}ngDeclareNgModule"; +const DECLARE_PIPE: &str = "\u{0275}\u{0275}ngDeclarePipe"; +const DECLARE_DIRECTIVE: &str = "\u{0275}\u{0275}ngDeclareDirective"; +const DECLARE_COMPONENT: &str = "\u{0275}\u{0275}ngDeclareComponent"; +const DECLARE_CLASS_METADATA: &str = "\u{0275}\u{0275}ngDeclareClassMetadata"; +const DECLARE_CLASS_METADATA_ASYNC: &str = "\u{0275}\u{0275}ngDeclareClassMetadataAsync"; + +/// Result of linking an Angular package file. +#[derive(Debug, Clone, Default)] +pub struct LinkResult { + /// The linked code. + pub code: String, + /// Source map (if enabled). + pub map: Option, + /// Whether any declarations were linked. + pub linked: bool, +} + +/// Link Angular partial declarations in a JavaScript file. +/// +/// Scans the code for `ɵɵngDeclare*` calls and replaces them with their +/// fully compiled equivalents. +pub fn link(allocator: &Allocator, code: &str, filename: &str) -> LinkResult { + // Quick check: if no declarations, return early + if !code.contains("\u{0275}\u{0275}ngDeclare") { + return LinkResult { code: code.to_string(), map: None, linked: false }; + } + + let source_type = SourceType::from_path(filename).unwrap_or(SourceType::mjs()); + let parser_result = Parser::new(allocator, code, source_type).parse(); + + if parser_result.panicked || !parser_result.errors.is_empty() { + return LinkResult { code: code.to_string(), map: None, linked: false }; + } + + let program = parser_result.program; + let mut edits: Vec = Vec::new(); + + // Walk all statements looking for ɵɵngDeclare* calls + collect_declaration_edits(&program, code, &mut edits); + + if edits.is_empty() { + return LinkResult { code: code.to_string(), map: None, linked: false }; + } + + let linked_code = crate::optimizer::apply_edits(code, edits); + + LinkResult { code: linked_code, map: None, linked: true } +} + +/// Recursively walk the AST to find all ɵɵngDeclare* calls and generate edits. +fn collect_declaration_edits(program: &Program<'_>, source: &str, edits: &mut Vec) { + for stmt in &program.body { + walk_statement(stmt, source, edits); + } +} + +/// Walk a statement looking for ɵɵngDeclare* calls. +fn walk_statement(stmt: &Statement<'_>, source: &str, edits: &mut Vec) { + match stmt { + Statement::ExpressionStatement(expr_stmt) => { + walk_expression(&expr_stmt.expression, source, edits); + } + Statement::ClassDeclaration(class_decl) => { + walk_class_body(&class_decl.body, source, edits); + } + Statement::VariableDeclaration(var_decl) => { + for decl in &var_decl.declarations { + if let Some(init) = &decl.init { + walk_expression(init, source, edits); + } + } + } + Statement::ReturnStatement(ret) => { + if let Some(ref arg) = ret.argument { + walk_expression(arg, source, edits); + } + } + Statement::BlockStatement(block) => { + for stmt in &block.body { + walk_statement(stmt, source, edits); + } + } + Statement::IfStatement(if_stmt) => { + walk_statement(&if_stmt.consequent, source, edits); + if let Some(ref alt) = if_stmt.alternate { + walk_statement(alt, source, edits); + } + } + Statement::ForStatement(for_stmt) => { + walk_statement(&for_stmt.body, source, edits); + } + Statement::ForInStatement(for_in) => { + walk_statement(&for_in.body, source, edits); + } + Statement::ForOfStatement(for_of) => { + walk_statement(&for_of.body, source, edits); + } + Statement::WhileStatement(while_stmt) => { + walk_statement(&while_stmt.body, source, edits); + } + Statement::DoWhileStatement(do_while) => { + walk_statement(&do_while.body, source, edits); + } + Statement::TryStatement(try_stmt) => { + for stmt in &try_stmt.block.body { + walk_statement(stmt, source, edits); + } + if let Some(ref handler) = try_stmt.handler { + for stmt in &handler.body.body { + walk_statement(stmt, source, edits); + } + } + if let Some(ref finalizer) = try_stmt.finalizer { + for stmt in &finalizer.body { + walk_statement(stmt, source, edits); + } + } + } + Statement::SwitchStatement(switch_stmt) => { + for case in &switch_stmt.cases { + for stmt in &case.consequent { + walk_statement(stmt, source, edits); + } + } + } + Statement::LabeledStatement(labeled) => { + walk_statement(&labeled.body, source, edits); + } + Statement::FunctionDeclaration(func_decl) => { + if let Some(ref body) = func_decl.body { + for stmt in &body.statements { + walk_statement(stmt, source, edits); + } + } + } + Statement::ExportNamedDeclaration(export_decl) => { + if let Some(ref decl) = export_decl.declaration { + walk_declaration(decl, source, edits); + } + } + Statement::ExportDefaultDeclaration(export_default) => match &export_default.declaration { + oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => { + walk_class_body(&class_decl.body, source, edits); + } + oxc_ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(func_decl) => { + if let Some(ref body) = func_decl.body { + for stmt in &body.statements { + walk_statement(stmt, source, edits); + } + } + } + _ => { + if let Some(expr) = export_default.declaration.as_expression() { + walk_expression(expr, source, edits); + } + } + }, + _ => {} + } +} + +/// Walk a class body looking for ɵɵngDeclare* calls in property definitions and static blocks. +fn walk_class_body(body: &oxc_ast::ast::ClassBody<'_>, source: &str, edits: &mut Vec) { + for element in &body.body { + if let oxc_ast::ast::ClassElement::PropertyDefinition(prop) = element { + if let Some(ref value) = prop.value { + walk_expression(value, source, edits); + } + } + if let oxc_ast::ast::ClassElement::StaticBlock(block) = element { + for stmt in &block.body { + walk_statement(stmt, source, edits); + } + } + if let oxc_ast::ast::ClassElement::MethodDefinition(method) = element { + if let Some(ref body) = method.value.body { + for stmt in &body.statements { + walk_statement(stmt, source, edits); + } + } + } + } +} + +/// Walk a declaration (from export statements) looking for ɵɵngDeclare* calls. +fn walk_declaration(decl: &oxc_ast::ast::Declaration<'_>, source: &str, edits: &mut Vec) { + match decl { + oxc_ast::ast::Declaration::VariableDeclaration(var_decl) => { + for d in &var_decl.declarations { + if let Some(init) = &d.init { + walk_expression(init, source, edits); + } + } + } + oxc_ast::ast::Declaration::FunctionDeclaration(func_decl) => { + if let Some(ref body) = func_decl.body { + for stmt in &body.statements { + walk_statement(stmt, source, edits); + } + } + } + oxc_ast::ast::Declaration::ClassDeclaration(class_decl) => { + walk_class_body(&class_decl.body, source, edits); + } + _ => {} + } +} + +/// Walk an expression looking for ɵɵngDeclare* calls. +fn walk_expression(expr: &Expression<'_>, source: &str, edits: &mut Vec) { + match expr { + Expression::CallExpression(call) => { + if let Some(name) = get_declare_name(call) { + if let Some(edit) = link_declaration(name, call, source) { + edits.push(edit); + return; + } + } + // Walk arguments recursively + for arg in &call.arguments { + if let Argument::SpreadElement(_) = arg { + continue; + } + walk_expression(arg.to_expression(), source, edits); + } + } + Expression::AssignmentExpression(assign) => { + walk_expression(&assign.right, source, edits); + } + Expression::SequenceExpression(seq) => { + for expr in &seq.expressions { + walk_expression(expr, source, edits); + } + } + Expression::ConditionalExpression(cond) => { + walk_expression(&cond.consequent, source, edits); + walk_expression(&cond.alternate, source, edits); + } + Expression::LogicalExpression(logical) => { + walk_expression(&logical.left, source, edits); + walk_expression(&logical.right, source, edits); + } + Expression::ParenthesizedExpression(paren) => { + walk_expression(&paren.expression, source, edits); + } + Expression::ArrowFunctionExpression(arrow) => { + for stmt in &arrow.body.statements { + walk_statement(stmt, source, edits); + } + } + Expression::FunctionExpression(func) => { + if let Some(body) = &func.body { + for stmt in &body.statements { + walk_statement(stmt, source, edits); + } + } + } + Expression::ClassExpression(class_expr) => { + walk_class_body(&class_expr.body, source, edits); + } + _ => {} + } +} + +/// Check if a call expression is a ɵɵngDeclare* call and return the declaration name. +fn get_declare_name<'a>(call: &'a CallExpression<'a>) -> Option<&'a str> { + let name = match &call.callee { + Expression::Identifier(ident) => ident.name.as_str(), + Expression::StaticMemberExpression(member) => member.property.name.as_str(), + _ => return None, + }; + + match name { + DECLARE_FACTORY + | DECLARE_INJECTABLE + | DECLARE_INJECTOR + | DECLARE_NG_MODULE + | DECLARE_PIPE + | DECLARE_DIRECTIVE + | DECLARE_COMPONENT + | DECLARE_CLASS_METADATA + | DECLARE_CLASS_METADATA_ASYNC => Some(name), + _ => None, + } +} + +/// Get the Angular import namespace (e.g., "i0") from the callee. +fn get_ng_import_namespace<'a>(call: &'a CallExpression<'a>) -> &'a str { + match &call.callee { + Expression::StaticMemberExpression(member) => { + if let Expression::Identifier(ident) = &member.object { + return ident.name.as_str(); + } + "i0" + } + _ => "i0", + } +} + +/// Get the metadata object from a ɵɵngDeclare* call's first argument. +fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExpression<'a>> { + call.arguments.first().and_then(|arg| { + if let Argument::ObjectExpression(obj) = arg { Some(obj.as_ref()) } else { None } + }) +} + +/// Extract a string property value from an object expression. +fn get_string_property<'a>(obj: &'a ObjectExpression<'a>, name: &str) -> Option<&'a str> { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) { + if let Expression::StringLiteral(lit) = &prop.value { + return Some(lit.value.as_str()); + } + } + } + } + None +} + +/// Extract the source text of a property value from an object expression. +fn get_property_source<'a>( + obj: &'a ObjectExpression<'a>, + name: &str, + source: &'a str, +) -> Option<&'a str> { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) { + let span = prop.value.span(); + return Some(&source[span.start as usize..span.end as usize]); + } + } + } + None +} + +/// Check if a property exists in an object expression. +fn has_property(obj: &ObjectExpression<'_>, name: &str) -> bool { + obj.properties.iter().any(|prop| { + matches!(prop, + ObjectPropertyKind::ObjectProperty(p) + if matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) + ) + }) +} + +/// Extract an object expression property value from an object expression. +fn get_object_property<'a>( + obj: &'a ObjectExpression<'a>, + name: &str, +) -> Option<&'a ObjectExpression<'a>> { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) { + if let Expression::ObjectExpression(inner) = &prop.value { + return Some(inner.as_ref()); + } + } + } + } + None +} + +/// Extract boolean property value. +fn get_bool_property(obj: &ObjectExpression<'_>, name: &str) -> Option { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) { + if let Expression::BooleanLiteral(lit) = &prop.value { + return Some(lit.value); + } + } + } + } + None +} + +/// Extract the `deps` array from a factory metadata object and generate inject calls. +fn extract_deps_source(obj: &ObjectExpression<'_>, source: &str, ns: &str) -> String { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == "deps") { + if let Expression::ArrayExpression(arr) = &prop.value { + if arr.elements.is_empty() { + return String::new(); + } + // Generate inject calls for each dependency + let deps: Vec = arr + .elements + .iter() + .filter_map(|el| { + use oxc_ast::ast::ArrayExpressionElement; + let expr = match el { + ArrayExpressionElement::SpreadElement(_) => return None, + _ => el.to_expression(), + }; + let span = expr.span(); + let dep_source = &source[span.start as usize..span.end as usize]; + + // Check if it's an object with token/attribute/flags + if let Expression::ObjectExpression(dep_obj) = expr { + let token = get_property_source(dep_obj.as_ref(), "token", source); + let optional = get_bool_property(dep_obj.as_ref(), "optional"); + let self_flag = get_bool_property(dep_obj.as_ref(), "self"); + let skip_self = get_bool_property(dep_obj.as_ref(), "skipSelf"); + let host = get_bool_property(dep_obj.as_ref(), "host"); + let attribute = + get_property_source(dep_obj.as_ref(), "attribute", source); + + if let Some(attr) = attribute { + return Some(format!( + "{ns}.\u{0275}\u{0275}injectAttribute({attr})" + )); + } + + if let Some(token) = token { + let mut flags = 0u32; + if optional == Some(true) { + flags |= 8; + } + if self_flag == Some(true) { + flags |= 2; + } + if skip_self == Some(true) { + flags |= 4; + } + if host == Some(true) { + flags |= 1; + } + if flags != 0 { + return Some(format!( + "{ns}.\u{0275}\u{0275}inject({token}, {flags})" + )); + } + return Some(format!("{ns}.\u{0275}\u{0275}inject({token})")); + } + Some(format!("{ns}.\u{0275}\u{0275}inject({dep_source})")) + } else { + Some(format!("{ns}.\u{0275}\u{0275}inject({dep_source})")) + } + }) + .collect(); + return deps.join(", "); + } + } + } + } + String::new() +} + +/// Parse a CSS selector string into Angular's internal selector array format. +/// +/// Angular represents selectors as nested arrays: +/// - `"app-root"` → `[["app-root"]]` +/// - `"[ngClass]"` → `[["", "ngClass", ""]]` +/// - `"[attr=value]"` → `[["", "attr", "value"]]` +/// - `"div[ngClass]"` → `[["div", "ngClass", ""]]` +/// - `"[a],[b]"` → `[["", "a", ""], ["", "b", ""]]` +/// - `".cls"` → `[["", "class", "cls"]]` +fn parse_selector(selector: &str) -> String { + let selectors: Vec = + selector.split(',').map(|s| parse_single_selector(s.trim())).collect(); + format!("[{}]", selectors.join(", ")) +} + +/// Parse a single selector (no commas) into Angular's array format. +fn parse_single_selector(selector: &str) -> String { + let mut parts: Vec = Vec::new(); + let mut remaining = selector; + + // Extract tag name (everything before first [ or . or :) + let tag_end = remaining + .find(|c: char| c == '[' || c == '.' || c == ':' || c == '#') + .unwrap_or(remaining.len()); + let tag = &remaining[..tag_end]; + remaining = &remaining[tag_end..]; + + if !tag.is_empty() { + parts.push(format!("\"{}\"", tag)); + } else { + parts.push("\"\"".to_string()); + } + + // Extract attribute selectors [attr] or [attr=value] + while let Some(bracket_start) = remaining.find('[') { + let bracket_end = remaining[bracket_start..].find(']').map(|i| bracket_start + i); + if let Some(end) = bracket_end { + let attr_content = &remaining[bracket_start + 1..end]; + if let Some(eq_pos) = attr_content.find('=') { + let attr_name = &attr_content[..eq_pos]; + let attr_value = attr_content[eq_pos + 1..].trim_matches('"').trim_matches('\''); + parts.push(format!("\"{}\"", attr_name)); + parts.push(format!("\"{}\"", attr_value)); + } else { + parts.push(format!("\"{}\"", attr_content)); + parts.push("\"\"".to_string()); + } + remaining = &remaining[end + 1..]; + } else { + break; + } + } + + // Extract class selectors .className + let mut class_remaining = remaining; + while let Some(dot_pos) = class_remaining.find('.') { + let class_end = class_remaining[dot_pos + 1..] + .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') + .map(|i| dot_pos + 1 + i) + .unwrap_or(class_remaining.len()); + let class_name = &class_remaining[dot_pos + 1..class_end]; + if !class_name.is_empty() { + parts.push("\"class\"".to_string()); + parts.push(format!("\"{}\"", class_name)); + } + class_remaining = &class_remaining[class_end..]; + } + + format!("[{}]", parts.join(", ")) +} + +/// Build the `hostAttrs` flat array from the partial declaration's `host` object. +/// +/// The `host` object in partial declarations has sub-properties: +/// - `attributes`: `{ "role": "tree", "tabindex": "-1" }` → `["role", "tree", "tabindex", "-1"]` +/// - `classAttribute`: `"cdk-tree"` → `[1, "cdk-tree"]` (1 = AttributeMarker.Classes) +/// - `styleAttribute`: `"display: block"` → `[2, "display: block"]` (2 = AttributeMarker.Styles) +/// +/// Properties and listeners go into `hostBindings`, not `hostAttrs`. +fn build_host_attrs(host_obj: &ObjectExpression<'_>, source: &str) -> String { + let mut attrs: Vec = Vec::new(); + + // Static attributes: { "role": "tree", "tabindex": "-1" } + if let Some(attr_obj) = get_object_property(host_obj, "attributes") { + for prop in &attr_obj.properties { + if let ObjectPropertyKind::ObjectProperty(p) = prop { + let key = match &p.key { + PropertyKey::StaticIdentifier(ident) => ident.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + let value_span = p.value.span(); + let value = &source[value_span.start as usize..value_span.end as usize]; + attrs.push(format!("\"{key}\"")); + attrs.push(value.to_string()); + } + } + } + + // Class attribute: "cdk-tree" → [1, "cdk-tree"] + // AttributeMarker.Classes = 1 + if let Some(class_attr) = get_string_property(host_obj, "classAttribute") { + // Split classes and add each separately + attrs.push("1".to_string()); // AttributeMarker.Classes + for class in class_attr.split_whitespace() { + attrs.push(format!("\"{class}\"")); + } + } + + // Style attribute: "display: block" → [2, "display", "block"] + // AttributeMarker.Styles = 2 + if let Some(style_attr) = get_string_property(host_obj, "styleAttribute") { + attrs.push("2".to_string()); // AttributeMarker.Styles + // Parse style string into key-value pairs + for declaration in style_attr.split(';') { + let declaration = declaration.trim(); + if declaration.is_empty() { + continue; + } + if let Some(colon_pos) = declaration.find(':') { + let prop = declaration[..colon_pos].trim(); + let val = declaration[colon_pos + 1..].trim(); + attrs.push(format!("\"{prop}\"")); + attrs.push(format!("\"{val}\"")); + } + } + } + + attrs.join(", ") +} + +/// Get the factory target from metadata. +fn get_factory_target(obj: &ObjectExpression<'_>, source: &str) -> &'static str { + if let Some(target_src) = get_property_source(obj, "target", source) { + if target_src.contains("Pipe") { + return "Pipe"; + } + if target_src.contains("Directive") || target_src.contains("Component") { + return "Directive"; + } + if target_src.contains("NgModule") { + return "NgModule"; + } + } + "Injectable" +} + +/// Link a single ɵɵngDeclare* call, generating the replacement code. +fn link_declaration(name: &str, call: &CallExpression<'_>, source: &str) -> Option { + let meta = get_metadata_object(call)?; + let ns = get_ng_import_namespace(call); + let type_name = get_property_source(meta, "type", source)?; + + let replacement = match name { + DECLARE_FACTORY => link_factory(meta, source, ns, type_name), + DECLARE_INJECTABLE => link_injectable(meta, source, ns, type_name), + DECLARE_INJECTOR => link_injector(meta, source, ns, type_name), + DECLARE_NG_MODULE => link_ng_module(meta, source, ns, type_name), + DECLARE_PIPE => link_pipe(meta, source, ns, type_name), + DECLARE_CLASS_METADATA => link_class_metadata(meta, source, ns, type_name), + DECLARE_CLASS_METADATA_ASYNC => link_class_metadata_async(meta, source, ns, type_name), + DECLARE_DIRECTIVE => link_directive(meta, source, ns, type_name), + // Skip component linking: template compilation is not yet implemented. + // Replacing ɵɵngDeclareComponent with an empty template would silently break + // all library component rendering. Leave the partial declaration intact so + // Angular's runtime can JIT-compile it via @angular/compiler. + DECLARE_COMPONENT => return None, + _ => return None, + }; + + let replacement = replacement?; + Some(Edit::replace(call.span.start, call.span.end, replacement)) +} + +/// Link ɵɵngDeclareFactory → factory function. +fn link_factory( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let target = get_factory_target(meta, source); + + // Check if deps are specified + let has_deps = has_property(meta, "deps"); + + if !has_deps { + // Inherited factory (no constructor) - use getInheritedFactory + Some(format!( + "/*@__PURE__*/ (() => {{\n\ + let \u{0275}{type_name}_BaseFactory;\n\ + return function {type_name}_Factory(__ngFactoryType__) {{\n\ + return (\u{0275}{type_name}_BaseFactory || (\u{0275}{type_name}_BaseFactory = {ns}.\u{0275}\u{0275}getInheritedFactory({type_name})))(__ngFactoryType__ || {type_name});\n\ + }};\n\ + }})()" + )) + } else { + let deps = extract_deps_source(meta, source, ns); + + if target == "Pipe" { + // Pipes use ɵɵdirectiveInject instead of ɵɵinject + let deps_pipe = deps.replace( + &format!("{ns}.\u{0275}\u{0275}inject("), + &format!("{ns}.\u{0275}\u{0275}directiveInject("), + ); + Some(format!( + "function {type_name}_Factory(__ngFactoryType__) {{\n\ + return new (__ngFactoryType__ || {type_name})({deps_pipe});\n\ + }}" + )) + } else if target == "Directive" { + let deps_dir = deps.replace( + &format!("{ns}.\u{0275}\u{0275}inject("), + &format!("{ns}.\u{0275}\u{0275}directiveInject("), + ); + Some(format!( + "function {type_name}_Factory(__ngFactoryType__) {{\n\ + return new (__ngFactoryType__ || {type_name})({deps_dir});\n\ + }}" + )) + } else { + Some(format!( + "function {type_name}_Factory(__ngFactoryType__) {{\n\ + return new (__ngFactoryType__ || {type_name})({deps});\n\ + }}" + )) + } + } +} + +/// Link ɵɵngDeclareInjectable → ɵɵdefineInjectable. +/// +/// For `useClass` and `useFactory` with deps, we generate a wrapper factory that calls +/// `ɵɵinject()` inside the factory body (deferred), not in a `deps` array (eager). +/// Eager inject calls would fail with NG0203 during static class initialization. +fn link_injectable( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let provided_in = get_property_source(meta, "providedIn", source).unwrap_or("null"); + + // Check for useClass, useFactory, useExisting, useValue + if let Some(use_class) = get_property_source(meta, "useClass", source) { + if has_property(meta, "deps") { + let deps = extract_deps_source(meta, source, ns); + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory() {{ return new ({use_class})({deps}); }}, providedIn: {provided_in} }})" + )); + } + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory() {{ return new ({use_class})(); }}, providedIn: {provided_in} }})" + )); + } + + if let Some(use_factory) = get_property_source(meta, "useFactory", source) { + if has_property(meta, "deps") { + let deps = extract_deps_source(meta, source, ns); + // Wrap the user factory: call inject() inside the wrapper, pass results as args + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory() {{ return ({use_factory})({deps}); }}, providedIn: {provided_in} }})" + )); + } + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: {use_factory}, providedIn: {provided_in} }})" + )); + } + + if let Some(use_existing) = get_property_source(meta, "useExisting", source) { + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory() {{ return {ns}.\u{0275}\u{0275}inject({use_existing}); }}, providedIn: {provided_in} }})" + )); + } + + if let Some(use_value) = get_property_source(meta, "useValue", source) { + return Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: function {type_name}_Factory() {{ return {use_value}; }}, providedIn: {provided_in} }})" + )); + } + + // Default: use the class factory + Some(format!( + "{ns}.\u{0275}\u{0275}defineInjectable({{ token: {type_name}, factory: {type_name}.\u{0275}fac, providedIn: {provided_in} }})" + )) +} + +/// Link ɵɵngDeclareInjector → ɵɵdefineInjector. +fn link_injector( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + // The injector definition uses type for consistency with other declarations + let mut parts = vec![format!("type: {type_name}")]; + + if let Some(providers) = get_property_source(meta, "providers", source) { + parts.push(format!("providers: {providers}")); + } + if let Some(imports) = get_property_source(meta, "imports", source) { + parts.push(format!("imports: {imports}")); + } + + Some(format!("{ns}.\u{0275}\u{0275}defineInjector({{ {} }})", parts.join(", "))) +} + +/// Link ɵɵngDeclareNgModule → ɵɵdefineNgModule. +fn link_ng_module( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let mut parts = vec![format!("type: {type_name}")]; + + if let Some(declarations) = get_property_source(meta, "declarations", source) { + parts.push(format!("declarations: {declarations}")); + } + if let Some(imports) = get_property_source(meta, "imports", source) { + parts.push(format!("imports: {imports}")); + } + if let Some(exports) = get_property_source(meta, "exports", source) { + parts.push(format!("exports: {exports}")); + } + if let Some(bootstrap) = get_property_source(meta, "bootstrap", source) { + parts.push(format!("bootstrap: {bootstrap}")); + } + if let Some(schemas) = get_property_source(meta, "schemas", source) { + parts.push(format!("schemas: {schemas}")); + } + + Some(format!("{ns}.\u{0275}\u{0275}defineNgModule({{ {} }})", parts.join(", "))) +} + +/// Link ɵɵngDeclarePipe → ɵɵdefinePipe. +fn link_pipe( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let pipe_name = get_string_property(meta, "name")?; + let pure = get_property_source(meta, "pure", source).unwrap_or("true"); + let standalone = get_property_source(meta, "isStandalone", source).unwrap_or("true"); + + Some(format!( + "{ns}.\u{0275}\u{0275}definePipe({{ name: \"{pipe_name}\", type: {type_name}, pure: {pure}, standalone: {standalone} }})" + )) +} + +/// Link ɵɵngDeclareClassMetadata → ɵɵsetClassMetadata. +fn link_class_metadata( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let decorators = get_property_source(meta, "decorators", source).unwrap_or("[]"); + let ctor_params = get_property_source(meta, "ctorParameters", source); + let prop_decorators = get_property_source(meta, "propDecorators", source); + + let ctor_str = ctor_params.unwrap_or("null"); + let prop_str = prop_decorators.unwrap_or("null"); + + Some(format!( + "(() => {{ (typeof ngDevMode === \"undefined\" || ngDevMode) && {ns}.\u{0275}setClassMetadata({type_name}, {decorators}, {ctor_str}, {prop_str}); }})()" + )) +} + +/// Link ɵɵngDeclareClassMetadataAsync → ɵɵsetClassMetadataAsync. +fn link_class_metadata_async( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let resolver_fn = get_property_source(meta, "resolveDeferredDeps", source)?; + let decorators = get_property_source(meta, "decorators", source).unwrap_or("[]"); + let ctor_params = get_property_source(meta, "ctorParameters", source); + let prop_decorators = get_property_source(meta, "propDecorators", source); + + let ctor_str = ctor_params.unwrap_or("null"); + let prop_str = prop_decorators.unwrap_or("null"); + + Some(format!( + "(() => {{ (typeof ngDevMode === \"undefined\" || ngDevMode) && {ns}.\u{0275}setClassMetadataAsync({type_name}, {resolver_fn}, () => {{ {ns}.\u{0275}setClassMetadata({type_name}, {decorators}, {ctor_str}, {prop_str}); }}); }})()" + )) +} + +/// Link ɵɵngDeclareDirective → ɵɵdefineDirective. +fn link_directive( + meta: &ObjectExpression<'_>, + source: &str, + ns: &str, + type_name: &str, +) -> Option { + let mut parts = vec![format!("type: {type_name}")]; + + if let Some(selector) = get_string_property(meta, "selector") { + parts.push(format!("selectors: {}", parse_selector(selector))); + } + if let Some(inputs) = get_property_source(meta, "inputs", source) { + parts.push(format!("inputs: {inputs}")); + } + if let Some(outputs) = get_property_source(meta, "outputs", source) { + parts.push(format!("outputs: {outputs}")); + } + if let Some(export_as) = get_property_source(meta, "exportAs", source) { + parts.push(format!("exportAs: {export_as}")); + } + let standalone = get_bool_property(meta, "isStandalone").unwrap_or(true); + parts.push(format!("standalone: {standalone}")); + + if let Some(host_directives) = get_property_source(meta, "hostDirectives", source) { + parts.push(format!("hostDirectives: {host_directives}")); + } + if let Some(features) = get_property_source(meta, "features", source) { + parts.push(format!("features: {features}")); + } + + // Host bindings - convert host object to hostAttrs array + if let Some(host_obj) = get_object_property(meta, "host") { + let host_attrs = build_host_attrs(host_obj, source); + if !host_attrs.is_empty() { + parts.push(format!("hostAttrs: [{}]", host_attrs)); + } + } + + Some(format!("{ns}.\u{0275}\u{0275}defineDirective({{ {} }})", parts.join(", "))) +} + +// NOTE: link_component is intentionally not implemented. +// Component linking requires full template compilation (parsing HTML templates +// into Angular instruction sequences like ɵɵelementStart, ɵɵtext, etc.). +// This is a major feature that needs a template compiler. +// Until implemented, ɵɵngDeclareComponent is left intact for Angular's +// runtime JIT compiler to handle. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_link_factory_with_deps() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyService { +} +MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("MyService_Factory")); + assert!(!result.code.contains("ɵɵngDeclareFactory")); + } + + #[test] + fn test_link_factory_inherited() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyService { +} +MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, target: i0.ɵɵFactoryTarget.Injectable }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("getInheritedFactory")); + assert!(!result.code.contains("ɵɵngDeclareFactory")); + } + + #[test] + fn test_link_injectable() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyService { +} +MyService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, providedIn: 'root' }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("defineInjectable")); + assert!(result.code.contains("providedIn: 'root'")); + assert!(!result.code.contains("ɵɵngDeclareInjectable")); + } + + #[test] + fn test_link_class_metadata() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("setClassMetadata")); + assert!(!result.code.contains("ɵɵngDeclareClassMetadata")); + } + + #[test] + fn test_parse_selector_tag() { + assert_eq!(parse_selector("app-root"), r#"[["app-root"]]"#); + } + + #[test] + fn test_parse_selector_attribute() { + assert_eq!(parse_selector("[ngClass]"), r#"[["", "ngClass", ""]]"#); + } + + #[test] + fn test_parse_selector_tag_with_attribute() { + assert_eq!(parse_selector("div[ngClass]"), r#"[["div", "ngClass", ""]]"#); + } + + #[test] + fn test_parse_selector_attribute_with_value() { + assert_eq!(parse_selector("[attr=value]"), r#"[["", "attr", "value"]]"#); + } + + #[test] + fn test_parse_selector_class() { + assert_eq!(parse_selector(".my-class"), r#"[["", "class", "my-class"]]"#); + } + + #[test] + fn test_parse_selector_multiple() { + assert_eq!(parse_selector("[a],[b]"), r#"[["", "a", ""], ["", "b", ""]]"#); + } + + #[test] + fn test_no_declarations() { + let allocator = Allocator::default(); + let code = "console.log('hello');"; + let result = link(&allocator, code, "test.mjs"); + assert!(!result.linked); + assert_eq!(result.code, code); + } + + #[test] + fn test_component_declarations_are_preserved() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
Hello
" }); +"#; + let result = link(&allocator, code, "test.mjs"); + // Component declarations should NOT be linked (template compilation not implemented). + // The original ɵɵngDeclareComponent call must be preserved intact so Angular's + // runtime can JIT-compile the template. + assert!(!result.linked); + assert!(result.code.contains("\u{0275}\u{0275}ngDeclareComponent")); + assert!(!result.code.contains("defineComponent")); + } + + #[test] + fn test_component_preserved_while_other_declarations_linked() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
Hello
" }); +"#; + let result = link(&allocator, code, "test.mjs"); + // Factory should be linked but component declaration should be preserved + assert!(result.linked); + assert!(result.code.contains("MyComponent_Factory")); + assert!(!result.code.contains("\u{0275}\u{0275}ngDeclareFactory")); + assert!(result.code.contains("\u{0275}\u{0275}ngDeclareComponent")); + } +} diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index b8028c717..71fc8ffa5 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -29,9 +29,14 @@ pub struct NgModuleMetadata<'a> { /// Declared components, directives, and pipes as class names. pub declarations: Vec<'a, Atom<'a>>, - /// Imported modules as class names. + /// Imported modules as class names (for ɵmod scope resolution). pub imports: Vec<'a, Atom<'a>>, + /// Raw imports array expression (for ɵinj provider resolution). + /// This preserves call expressions like `StoreModule.forRoot(...)` and spread elements + /// that are needed by the injector to resolve `ModuleWithProviders`. + pub raw_imports_expr: Option>, + /// Exported declarations and modules as class names. pub exports: Vec<'a, Atom<'a>>, @@ -64,6 +69,7 @@ impl<'a> NgModuleMetadata<'a> { class_span, declarations: Vec::new_in(allocator), imports: Vec::new_in(allocator), + raw_imports_expr: None, exports: Vec::new_in(allocator), providers: None, bootstrap: Vec::new_in(allocator), @@ -226,6 +232,10 @@ pub fn extract_ng_module_metadata<'a>( if has_forward_refs { metadata.contains_forward_decls = true; } + // Also store the raw imports expression for ɵinj generation. + // This preserves call expressions like StoreModule.forRoot(...) + // and spread elements that are dropped by extract_reference_array. + metadata.raw_imports_expr = convert_oxc_expression(allocator, &prop.value); } "exports" => { let (identifiers, has_forward_refs) = @@ -334,6 +344,7 @@ fn extract_reference_array<'a>( result.push(id.name.clone()); } // Forward reference: forwardRef(() => SomeComponent) + // Or method call: StoreModule.forRoot(...), EffectsModule.forRoot([...]) ArrayExpressionElement::CallExpression(call) => { if let Expression::Identifier(id) = &call.callee { if id.name == "forwardRef" { @@ -346,6 +357,12 @@ fn extract_reference_array<'a>( } } } + } else if let Expression::StaticMemberExpression(member) = &call.callee { + // Module.forRoot(...) or Module.forChild(...) pattern + // Extract the base class identifier for ɵmod scope resolution + if let Expression::Identifier(id) = &member.object { + result.push(id.name.clone()); + } } } // Spread element or other complex expressions - skip diff --git a/crates/oxc_angular_compiler/src/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index 65ffd6a75..7f98c5e55 100644 --- a/crates/oxc_angular_compiler/src/ng_module/definition.rs +++ b/crates/oxc_angular_compiler/src/ng_module/definition.rs @@ -276,14 +276,19 @@ fn generate_ng_module_inj<'a>( builder = builder.providers(providers.clone_in(allocator)); } - // Add imports for the injector - // The injector needs to know about imported modules to resolve their providers - for import in &metadata.imports { - let import_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: import.clone(), source_span: None }, - allocator, - )); - builder = builder.add_import(import_expr); + // Add imports for the injector. + // Prefer raw_imports_expr which preserves call expressions like StoreModule.forRoot(...) + // and spread elements, needed for ModuleWithProviders provider resolution. + if let Some(raw_imports) = &metadata.raw_imports_expr { + builder = builder.raw_imports(raw_imports.clone_in(allocator)); + } else { + for import in &metadata.imports { + let import_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: import.clone(), source_span: None }, + allocator, + )); + builder = builder.add_import(import_expr); + } } let inj_metadata = builder.build().expect("Failed to build injector metadata"); diff --git a/crates/oxc_angular_compiler/src/optimizer/mod.rs b/crates/oxc_angular_compiler/src/optimizer/mod.rs index a52e10b83..3d5858d5a 100644 --- a/crates/oxc_angular_compiler/src/optimizer/mod.rs +++ b/crates/oxc_angular_compiler/src/optimizer/mod.rs @@ -200,7 +200,7 @@ impl Edit { /// /// Edits are sorted by position (descending) so that applying them /// from the end doesn't invalidate earlier positions. -fn apply_edits(code: &str, mut edits: Vec) -> String { +pub fn apply_edits(code: &str, mut edits: Vec) -> String { if edits.is_empty() { return code.to_string(); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs index 11edbeffc..1a8f950d2 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs @@ -824,9 +824,20 @@ fn wrap_with_postprocess<'a>( InvokeFunctionExpr, LiteralArrayExpr, LiteralMapEntry, LiteralMapExpr, }; - // Create ɵɵi18nPostprocess function reference - let fn_var = OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: Atom::from(Identifiers::I18N_POSTPROCESS), source_span: None }, + // Create ɵɵi18nPostprocess function reference (i0.ɵɵi18nPostprocess) + let fn_var = OutputExpression::ReadProp(oxc_allocator::Box::new_in( + crate::output::ast::ReadPropExpr { + receiver: oxc_allocator::Box::new_in( + OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: Atom::from("i0"), source_span: None }, + allocator, + )), + allocator, + ), + name: Atom::from(Identifiers::I18N_POSTPROCESS), + optional: false, + source_span: None, + }, allocator, )); @@ -880,3 +891,79 @@ fn wrap_with_postprocess<'a>( allocator, )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::ast::{LiteralExpr, OutputExpression, ReadVarExpr}; + use crate::output::emitter::JsEmitter; + use oxc_allocator::Allocator; + use oxc_span::Atom; + + #[test] + fn test_wrap_with_postprocess_uses_namespace_prefix() { + // Regression test for bug where wrap_with_postprocess() created a bare + // ReadVar(ɵɵi18nPostprocess) instead of ReadProp(i0.ɵɵi18nPostprocess). + // At runtime this caused: ReferenceError: ɵɵi18nPostprocess is not defined + // + // The fix: Changed to use ReadProp(i0.ɵɵi18nPostprocess) so the function + // is properly accessed through the Angular core namespace import. + let allocator = Allocator::default(); + + // Create a simple input expression (simulating a $localize result) + let input_expr = OutputExpression::Literal(oxc_allocator::Box::new_in( + LiteralExpr { + value: LiteralValue::String(Atom::from("test message")), + source_span: None, + }, + &allocator, + )); + + // Call wrap_with_postprocess with no extra params + let result = wrap_with_postprocess(&allocator, input_expr, &[]); + + // Emit the result to a string and verify + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&result); + + // The output must contain "i0.ɵɵi18nPostprocess" (namespace-prefixed), + // NOT a bare "ɵɵi18nPostprocess" without the i0. prefix. + assert!( + output.contains("i0.ɵɵi18nPostprocess"), + "wrap_with_postprocess should emit i0.ɵɵi18nPostprocess (with namespace prefix), but got:\n{}", + output + ); + } + + #[test] + fn test_wrap_with_postprocess_with_params_uses_namespace_prefix() { + // Same as above but with postprocessing params to test the full path. + let allocator = Allocator::default(); + + let input_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: Atom::from("i18n_0"), source_span: None }, + &allocator, + )); + + let params = vec![("ICU_0".to_string(), vec!["i18n_1".to_string(), "i18n_2".to_string()])]; + + let result = wrap_with_postprocess(&allocator, input_expr, ¶ms); + + let emitter = JsEmitter::new(); + let output = emitter.emit_expression(&result); + + // Verify namespace prefix is present + assert!( + output.contains("i0.ɵɵi18nPostprocess"), + "wrap_with_postprocess with params should emit i0.ɵɵi18nPostprocess, but got:\n{}", + output + ); + + // Verify the function is called with the expression and the params map + assert!( + output.contains("i18n_0"), + "Should contain the input expression, but got:\n{}", + output + ); + } +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index b14e4da31..f5f92a2f4 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -4695,3 +4695,432 @@ fn test_defer_loading_timer_consts_after_i18n_consts() { insta::assert_snapshot!("defer_loading_timer_consts_after_i18n_consts", js); } + +// ============================================================================= +// Regression tests for runtime bugs found during ClickUp integration +// ============================================================================= + +/// Regression: Directive constructor DI tokens must be namespace-prefixed. +/// +/// Bug: `@Directive` classes that inject services from external modules (e.g., `Store` from +/// `@ngrx/store`) emitted bare identifiers like `ɵɵdirectiveInject(Store)` instead of +/// namespace-prefixed `ɵɵdirectiveInject(i1.Store)`. At runtime TypeScript elides the bare +/// `Store` import (it's type-only), causing `ASSERTION ERROR: token must be defined`. +/// +/// Root cause (two parts): +/// 1. `extract_param_token()` in directive/decorator.rs returned `ReadProp(i0.TypeName)` with +/// a hardcoded `i0` prefix, instead of `ReadVar(TypeName)` like injectable/pipe/ng_module. +/// 2. `transform_angular_file()` did not call `resolve_factory_dep_namespaces()` for directive +/// deps, so even corrected `ReadVar` tokens would not get namespace-resolved. +/// +/// Fix: Return `ReadVar(TypeName)` from `extract_param_token()` AND call +/// `resolve_factory_dep_namespaces()` for directive deps in transform.rs. +#[test] +fn test_directive_factory_deps_use_namespace_prefixed_tokens() { + let allocator = Allocator::default(); + + // Simulate the ClickUp pattern: a directive injecting services from multiple modules + let source = r#" +import { Directive } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { ToastService } from './toast.service'; + +@Directive({ + selector: '[appToastPosition]', + standalone: true, +}) +export class ToastPositionHelperDirective { + constructor( + private store: Store, + private toastService: ToastService, + ) {} +} +"#; + + let result = transform_angular_file( + &allocator, + "toast-position-helper.directive.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Factory function should use namespace-prefixed tokens for DI + // Store comes from @ngrx/store (i1), ToastService from ./toast.service (i2) + assert!( + code.contains("i1.Store"), + "Factory should use namespace-prefixed i1.Store for @ngrx/store import. Output:\n{code}" + ); + assert!( + code.contains("i2.ToastService"), + "Factory should use namespace-prefixed i2.ToastService for ./toast.service import. Output:\n{code}" + ); + + // Must NOT have bare (un-prefixed) tokens in directiveInject calls + // A bare `directiveInject(Store)` would fail at runtime because TypeScript elides the import + let factory_section = + code.split("ɵfac").nth(1).expect("Should have a factory definition (ɵfac)"); + + assert!( + !factory_section.contains("directiveInject(Store)"), + "Factory must NOT use bare 'Store' - TypeScript would elide this import. Factory:\n{factory_section}" + ); + assert!( + !factory_section.contains("directiveInject(ToastService)"), + "Factory must NOT use bare 'ToastService' - TypeScript would elide this import. Factory:\n{factory_section}" + ); +} + +/// Regression: Directive with multiple DI deps from different modules gets correct namespace indices. +/// +/// Each imported module should get a unique namespace alias (i0=@angular/core, i1=first import, +/// i2=second import, etc.). This test verifies the namespace registry correctly assigns indices +/// when a directive has dependencies from multiple external modules. +#[test] +fn test_directive_multiple_deps_different_modules_correct_namespaces() { + let allocator = Allocator::default(); + + let source = r#" +import { Directive, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { FormBuilder } from '@angular/forms'; + +@Directive({ + selector: '[appMultiDep]', + standalone: true, +}) +export class MultiDepDirective { + constructor( + private el: ElementRef, + private router: Router, + private http: HttpClient, + private fb: FormBuilder, + ) {} +} +"#; + + let result = transform_angular_file( + &allocator, + "multi-dep.directive.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // ElementRef is from @angular/core (i0) - should be i0.ElementRef + assert!( + code.contains("i0.ElementRef"), + "ElementRef from @angular/core should use i0 namespace. Output:\n{code}" + ); + + // Each external module should get its own namespace (i1, i2, i3) + // The exact indices depend on registration order, but each should be namespace-prefixed + assert!(code.contains(".Router"), "Router should be namespace-prefixed. Output:\n{code}"); + assert!( + code.contains(".HttpClient"), + "HttpClient should be namespace-prefixed. Output:\n{code}" + ); + assert!( + code.contains(".FormBuilder"), + "FormBuilder should be namespace-prefixed. Output:\n{code}" + ); + + // Count the namespace import declarations - should have i0 + at least 3 more + let namespace_imports: Vec<&str> = + code.lines().filter(|l| l.contains("import * as i")).collect(); + assert!( + namespace_imports.len() >= 4, + "Should have at least 4 namespace imports (i0..i3). Found {}:\n{}", + namespace_imports.len(), + namespace_imports.join("\n") + ); +} + +/// Regression: `ɵɵi18nPostprocess` must use namespace prefix `i0.ɵɵi18nPostprocess`. +/// +/// Bug: `wrap_with_postprocess()` in i18n_const_collection.rs used a bare +/// `ReadVar(ɵɵi18nPostprocess)` which emitted `ɵɵi18nPostprocess(...)` without the `i0.` +/// namespace prefix. At runtime: `ReferenceError: ɵɵi18nPostprocess is not defined`. +/// +/// The postprocess function is only called for ICU messages that need sub-expression +/// replacement (e.g., nested plural/select with multiple sub-messages). Simple i18n +/// messages don't trigger this code path. +/// +/// Fix: Changed to `ReadProp(i0.ɵɵi18nPostprocess)` matching all other Angular runtime calls. +#[test] +fn test_i18n_icu_postprocess_uses_namespace_prefix() { + let allocator = Allocator::default(); + + // An ICU plural with sub-messages triggers ɵɵi18nPostprocess. + // This is the pattern from ClickUp's ChatBotTriggerComponent. + let source = r#" +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-chatbot', + standalone: true, + template: `{count, plural, =1 {{{ name }} item} other {{{ count }} items}}`, +}) +export class ChatBotTriggerComponent { + count = 0; + name = ''; +} +"#; + + let result = transform_angular_file( + &allocator, + "chatbot-trigger.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // If i18nPostprocess is present, it MUST be namespace-prefixed + if code.contains("i18nPostprocess") { + assert!( + code.contains("i0.ɵɵi18nPostprocess"), + "ɵɵi18nPostprocess must be namespace-prefixed as i0.ɵɵi18nPostprocess. \ + A bare 'ɵɵi18nPostprocess' causes ReferenceError at runtime. Output:\n{code}" + ); + + // Must NOT have a bare (un-prefixed) call + // Check that there's no `ɵɵi18nPostprocess` without `i0.` before it + for (i, _) in code.match_indices("ɵɵi18nPostprocess") { + let prefix = &code[..i]; + assert!( + prefix.ends_with("i0."), + "Found bare ɵɵi18nPostprocess without i0. prefix at position {i}. Output:\n{code}" + ); + } + } +} + +/// Regression: Multiple view queries must emit separate statements, not chained calls. +/// +/// Bug: Multiple `@ViewChild`/`@ViewChildren` queries were chained as +/// `ɵɵviewQuery(pred1)(pred2)`, treating the return value of `ɵɵviewQuery` as a callable. +/// Angular 20's `ɵɵviewQuery` returns `void`, so chaining causes: +/// `TypeError: i0.ɵɵviewQuery(...) is not a function`. +/// +/// Fix: Emit each query as a separate statement: +/// `ɵɵviewQuery(pred1); ɵɵviewQuery(pred2);` +#[test] +fn test_multiple_view_queries_emit_separate_statements() { + let allocator = Allocator::default(); + + // Reproduce the ClickUp LoginFormComponent pattern: multiple @ViewChild decorators + let source = r#" +import { Component, ViewChild, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-login', + template: '', +}) +export class LoginFormComponent { + @ViewChild('emailInput') emailInput: ElementRef; + @ViewChild('passwordInput') passwordInput: ElementRef; + @ViewChild('submitBtn') submitBtn: ElementRef; +} +"#; + + let result = transform_angular_file( + &allocator, + "login-form.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Count separate ɵɵviewQuery calls - should be 3 (one per @ViewChild) + let view_query_count = code.matches("ɵɵviewQuery(").count(); + assert_eq!( + view_query_count, 3, + "Should have exactly 3 separate ɵɵviewQuery calls. Found {view_query_count}. Output:\n{code}" + ); + + // Must NOT have chained calls: ɵɵviewQuery(...)(...) pattern + // This regex-free check: after each `ɵɵviewQuery(` find the matching `)` and check + // the next non-whitespace char is NOT `(` + let query_fn = "ɵɵviewQuery("; + for (start_idx, _) in code.match_indices(query_fn) { + let after_fn = &code[start_idx + query_fn.len()..]; + // Find the closing paren (handle nested parens) + let mut depth = 1; + let mut end = 0; + for (i, ch) in after_fn.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = i + 1; + break; + } + } + _ => {} + } + } + // Check what comes after the closing paren + let after_close = after_fn[end..].trim_start(); + assert!( + !after_close.starts_with('('), + "Found chained ɵɵviewQuery call (return value used as function). \ + Angular 20's ɵɵviewQuery returns void. Output:\n{code}" + ); + } +} + +/// Regression: Multiple content queries must emit separate statements, not chained calls. +/// +/// Same issue as view queries but for `@ContentChild`/`@ContentChildren`. +/// The fix applies to both `create_view_queries_function` and `create_content_queries_function`. +#[test] +fn test_multiple_content_queries_emit_separate_statements() { + let allocator = Allocator::default(); + + let source = r#" +import { Component, ContentChild, ContentChildren, QueryList, TemplateRef } from '@angular/core'; + +@Component({ + selector: 'app-tabs', + template: '', +}) +export class TabsComponent { + @ContentChild('header') header: TemplateRef; + @ContentChildren('tab') tabs: QueryList>; + @ContentChild('footer') footer: TemplateRef; +} +"#; + + let result = transform_angular_file( + &allocator, + "tabs.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Count separate ɵɵcontentQuery calls - should be 3 + let content_query_count = code.matches("ɵɵcontentQuery(").count(); + assert_eq!( + content_query_count, 3, + "Should have exactly 3 separate ɵɵcontentQuery calls. Found {content_query_count}. Output:\n{code}" + ); + + // Must NOT have chained calls + let query_fn = "ɵɵcontentQuery("; + for (start_idx, _) in code.match_indices(query_fn) { + let after_fn = &code[start_idx + query_fn.len()..]; + let mut depth = 1; + let mut end = 0; + for (i, ch) in after_fn.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = i + 1; + break; + } + } + _ => {} + } + } + let after_close = after_fn[end..].trim_start(); + assert!( + !after_close.starts_with('('), + "Found chained ɵɵcontentQuery call. Angular 20's query functions return void. Output:\n{code}" + ); + } +} + +/// Regression: Mixed view queries (signal + decorator) must all be separate statements. +/// +/// Signal-based queries (`viewChild()`, `viewChildren()`) and decorator-based queries +/// (`@ViewChild`, `@ViewChildren`) can coexist on the same component. All of them must +/// emit as separate statements. +#[test] +fn test_mixed_signal_and_decorator_view_queries_separate_statements() { + let allocator = Allocator::default(); + + let source = r#" +import { Component, ViewChild, viewChild, viewChildren, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-mixed', + template: '
', +}) +export class MixedQueryComponent { + a = viewChild('a'); + b = viewChildren('b'); + @ViewChild('c') c: ElementRef; +} +"#; + + let result = transform_angular_file( + &allocator, + "mixed-query.component.ts", + source, + &ComponentTransformOptions::default(), + None, + ); + + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + let code = &result.code; + + // Should have signal query calls AND decorator query calls, all separate + let total_query_calls = code.matches("ɵɵviewQuery").count(); + assert!( + total_query_calls >= 3, + "Should have at least 3 view query calls (signal + decorator). Found {total_query_calls}. Output:\n{code}" + ); + + // Verify no chaining for any view query variant + for query_fn in ["ɵɵviewQuerySignal(", "ɵɵviewQuery("] { + for (start_idx, _) in code.match_indices(query_fn) { + let after_fn = &code[start_idx + query_fn.len()..]; + let mut depth = 1; + let mut end = 0; + for (i, ch) in after_fn.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = i + 1; + break; + } + } + _ => {} + } + } + let after_close = after_fn[end..].trim_start(); + assert!( + !after_close.starts_with('('), + "Found chained {query_fn} call. Output:\n{code}" + ); + } + } +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/directive-factory-namespace.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/directive-factory-namespace.fixture.ts new file mode 100644 index 000000000..554bd05e8 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/directive-factory-namespace.fixture.ts @@ -0,0 +1,81 @@ +/** + * Regression: Directive factory DI tokens must be namespace-prefixed. + * + * ## The Issue + * + * `@Directive` classes that inject services from external modules emitted bare + * identifiers like `ɵɵdirectiveInject(Store)` instead of namespace-prefixed + * `ɵɵdirectiveInject(i1.Store)`. At runtime TypeScript elides the bare import + * (it's type-only), causing `ASSERTION ERROR: token must be defined`. + * + * ## Root Cause (two parts) + * + * 1. `extract_param_token()` in directive/decorator.rs returned `ReadProp(i0.TypeName)` + * with a hardcoded `i0` prefix, instead of `ReadVar(TypeName)` like injectable/pipe. + * 2. `transform_angular_file()` did not call `resolve_factory_dep_namespaces()` for + * directive deps. + * + * ## Fix + * + * Return `ReadVar(TypeName)` from `extract_param_token()` AND call + * `resolve_factory_dep_namespaces()` for directive deps in transform.rs. + * + * ## Impact + * + * RUNTIME_AFFECTING: Without the fix, any directive with constructor DI from external + * modules would crash at bootstrap with `ASSERTION ERROR: token must be defined`. + * Discovered in ClickUp's ToastPositionHelperDirective which injects Store from @ngrx/store. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + type: 'full-transform', + name: 'directive-factory-namespace-basic', + category: 'regressions', + description: 'Directive with external DI deps should use namespace-prefixed tokens', + className: 'ToastPositionHelperDirective', + sourceCode: ` +import { Directive, ElementRef } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +@Directive({ + selector: '[appToastPosition]', + standalone: true, +}) +export class ToastPositionHelperDirective { + constructor( + private el: ElementRef, + private http: HttpClient, + ) {} +} +`.trim(), + expectedFeatures: ['ɵɵdirectiveInject', 'ɵɵdefineDirective'], + }, + { + type: 'full-transform', + name: 'directive-factory-namespace-multi-module', + category: 'regressions', + description: + 'Directive injecting from multiple external modules gets correct namespace indices', + className: 'MultiDepDirective', + sourceCode: ` +import { Directive, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; + +@Directive({ + selector: '[appMultiDep]', + standalone: true, +}) +export class MultiDepDirective { + constructor( + private el: ElementRef, + private router: Router, + private http: HttpClient, + ) {} +} +`.trim(), + expectedFeatures: ['ɵɵdirectiveInject', 'ɵɵdefineDirective'], + }, +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/i18n-postprocess-namespace.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/i18n-postprocess-namespace.fixture.ts new file mode 100644 index 000000000..8a1f585e4 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/i18n-postprocess-namespace.fixture.ts @@ -0,0 +1,90 @@ +/** + * Regression: `ɵɵi18nPostprocess` must use namespace prefix `i0.ɵɵi18nPostprocess`. + * + * ## The Issue + * + * `wrap_with_postprocess()` in i18n_const_collection.rs used a bare + * `ReadVar(ɵɵi18nPostprocess)` which emitted `ɵɵi18nPostprocess(...)` without + * the `i0.` namespace prefix. At runtime: + * `ReferenceError: ɵɵi18nPostprocess is not defined`. + * + * ## Root Cause + * + * The function was constructing `OutputExpression::ReadVar` for the runtime helper, + * but all Angular runtime functions must be accessed through the namespace import + * (`i0.ɵɵ...`). The correct expression is `OutputExpression::ReadProp` with + * `i0` as the receiver. + * + * ## Fix + * + * Changed to `ReadProp(i0.ɵɵi18nPostprocess)` matching all other Angular + * runtime calls. + * + * ## Impact + * + * RUNTIME_AFFECTING: Any component with nested ICU expressions (plural/select with + * sub-messages) would crash with ReferenceError. Discovered in ClickUp's + * ChatBotTriggerComponent which uses plural with HTML elements in branches. + * + * ## When ɵɵi18nPostprocess is triggered + * + * The postprocess function is called when ICU messages contain: + * - Nested plural/select expressions with sub-messages + * - HTML elements inside ICU branches (e.g., `{{ name }}`) + * - Multiple sub-expressions that need placeholder replacement + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + type: 'full-transform', + name: 'i18n-postprocess-namespace-nested-icu', + category: 'regressions', + description: 'Nested ICU with HTML elements should use i0.ɵɵi18nPostprocess', + className: 'ChatBotTriggerComponent', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-chatbot', + standalone: true, + template: \`{count, plural, =1 {{{ name }} item} other {{{ count }} items}}\`, +}) +export class ChatBotTriggerComponent { + count = 0; + name = ''; +} +`.trim(), + expectedFeatures: ['ɵɵi18n', 'ɵɵi18nExp'], + }, + { + type: 'full-transform', + name: 'i18n-postprocess-namespace-nested-select', + category: 'regressions', + description: 'Nested select ICU should use namespace-prefixed postprocess', + className: 'UndoToastComponent', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-undo-toast', + standalone: true, + template: \`{count, plural, + =1 {{{ name }} was deleted from {nestedCount, plural, + =1 {{{ category }}} + other {{{ category }} and {{ extra }} more} + }} + other {{{ count }} items deleted} + }\`, +}) +export class UndoToastComponent { + count = 0; + name = ''; + nestedCount = 0; + category = ''; + extra = 0; +} +`.trim(), + expectedFeatures: ['ɵɵi18n', 'ɵɵi18nExp'], + }, +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/query-chaining.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/query-chaining.fixture.ts new file mode 100644 index 000000000..1d41bba46 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/query-chaining.fixture.ts @@ -0,0 +1,98 @@ +/** + * Regression: Multiple queries must emit separate statements, not chained calls. + * + * ## The Issue + * + * Multiple `@ViewChild`/`@ViewChildren`/`@ContentChild`/`@ContentChildren` queries + * were chained as `ɵɵviewQuery(pred1)(pred2)`, treating the return value of + * `ɵɵviewQuery` as a callable. Angular 20's `ɵɵviewQuery` returns `void`, so + * chaining causes: `TypeError: i0.ɵɵviewQuery(...) is not a function`. + * + * ## Root Cause + * + * `create_view_queries_function` and `create_content_queries_function` in + * directive/query.rs built a chain of calls by wrapping each new query call around + * the previous one's return value. + * + * ## Fix + * + * Emit each query as a separate statement: + * `ɵɵviewQuery(pred1); ɵɵviewQuery(pred2);` + * + * ## Impact + * + * RUNTIME_AFFECTING: Any component with 2+ view queries or 2+ content queries + * would crash at bootstrap. Discovered in ClickUp's LoginFormComponent. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + type: 'full-transform', + name: 'query-chaining-multiple-viewchild', + category: 'regressions', + description: 'Multiple @ViewChild should emit separate ɵɵviewQuery statements', + className: 'LoginFormComponent', + sourceCode: ` +import { Component, ViewChild, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-login', + standalone: true, + template: '', +}) +export class LoginFormComponent { + @ViewChild('emailInput') emailInput!: ElementRef; + @ViewChild('passwordInput') passwordInput!: ElementRef; + @ViewChild('submitBtn') submitBtn!: ElementRef; +} +`.trim(), + expectedFeatures: ['ɵɵviewQuery', 'ɵɵqueryRefresh', 'ɵɵloadQuery'], + }, + { + type: 'full-transform', + name: 'query-chaining-multiple-contentchild', + category: 'regressions', + description: + 'Multiple @ContentChild/@ContentChildren should emit separate ɵɵcontentQuery statements', + className: 'TabsComponent', + sourceCode: ` +import { Component, ContentChild, ContentChildren, QueryList, TemplateRef } from '@angular/core'; + +@Component({ + selector: 'app-tabs', + standalone: true, + template: '', +}) +export class TabsComponent { + @ContentChild('header') header!: TemplateRef; + @ContentChildren('tab') tabs!: QueryList>; + @ContentChild('footer') footer!: TemplateRef; +} +`.trim(), + expectedFeatures: ['ɵɵcontentQuery', 'ɵɵqueryRefresh', 'ɵɵloadQuery'], + }, + { + type: 'full-transform', + name: 'query-chaining-mixed-view-and-content', + category: 'regressions', + description: 'Mixed ViewChild and ContentChild queries should all emit separate statements', + className: 'MixedQueryComponent', + sourceCode: ` +import { Component, ViewChild, ContentChild, ElementRef, TemplateRef } from '@angular/core'; + +@Component({ + selector: 'app-mixed-queries', + standalone: true, + template: '
', +}) +export class MixedQueryComponent { + @ViewChild('viewRef') viewRef!: ElementRef; + @ViewChild('secondView') secondView!: ElementRef; + @ContentChild('contentRef') contentRef!: TemplateRef; + @ContentChild('secondContent') secondContent!: TemplateRef; +} +`.trim(), + expectedFeatures: ['ɵɵviewQuery', 'ɵɵcontentQuery', 'ɵɵqueryRefresh'], + }, +] diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 4e4344a23..d00654182 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -534,6 +534,46 @@ export interface InjectorNapiCompileResult { errors: Array } +/** + * Link Angular partial declarations in a JavaScript file (async). + * + * This is the async version of `linkAngularPackageSync`. Use this when + * linking packages in a non-blocking context. + */ +export declare function linkAngularPackage(code: string, filename: string): Promise + +/** + * Link Angular partial declarations in a JavaScript file (sync). + * + * Processes pre-compiled Angular library code containing `ɵɵngDeclare*` calls + * and converts them to their fully compiled equivalents (`ɵɵdefine*` calls). + * + * This is necessary for Angular libraries published with partial compilation + * (Angular 12+). Without linking, Angular falls back to JIT compilation + * which requires `@angular/compiler` at runtime. + * + * # Arguments + * + * * `code` - The JavaScript source code to link + * * `filename` - The filename (for source maps and error messages) + * + * # Returns + * + * A `LinkResult` containing the linked code. If no partial declarations + * were found, the original code is returned with `linked: false`. + */ +export declare function linkAngularPackageSync(code: string, filename: string): LinkResult + +/** Result of linking Angular partial declarations. */ +export interface LinkResult { + /** The linked code. */ + code: string + /** Source map (if enabled). */ + map?: string + /** Whether any declarations were linked. */ + linked: boolean +} + /** * Optimize an Angular package file for better tree-shaking (async). * diff --git a/napi/angular-compiler/index.js b/napi/angular-compiler/index.js index a8689b7e5..022534524 100644 --- a/napi/angular-compiler/index.js +++ b/napi/angular-compiler/index.js @@ -777,6 +777,8 @@ const { extractComponentUrls, generateHmrModule, generateStyleModule, + linkAngularPackage, + linkAngularPackageSync, optimizeAngularPackage, parseComponentId, transformAngularFile, @@ -795,6 +797,8 @@ export { extractAngularComponentByAst } export { extractComponentUrls } export { generateHmrModule } export { generateStyleModule } +export { linkAngularPackage } +export { linkAngularPackageSync } export { optimizeAngularPackage } export { parseComponentId } export { transformAngularFile } diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 8e039305c..113ebd28f 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -2604,3 +2604,78 @@ pub fn optimize_angular_package( ) -> AsyncTask { AsyncTask::new(OptimizeAngularPackageTask { code, filename, options }) } + +// ============================================================================= +// Angular Partial Declaration Linker +// ============================================================================= + +/// Result of linking Angular partial declarations. +#[napi(object)] +pub struct LinkResult { + /// The linked code. + pub code: String, + /// Source map (if enabled). + pub map: Option, + /// Whether any declarations were linked. + pub linked: bool, +} + +/// Link Angular partial declarations in a JavaScript file (sync). +/// +/// Processes pre-compiled Angular library code containing `ɵɵngDeclare*` calls +/// and converts them to their fully compiled equivalents (`ɵɵdefine*` calls). +/// +/// This is necessary for Angular libraries published with partial compilation +/// (Angular 12+). Without linking, Angular falls back to JIT compilation +/// which requires `@angular/compiler` at runtime. +/// +/// # Arguments +/// +/// * `code` - The JavaScript source code to link +/// * `filename` - The filename (for source maps and error messages) +/// +/// # Returns +/// +/// A `LinkResult` containing the linked code. If no partial declarations +/// were found, the original code is returned with `linked: false`. +#[napi] +pub fn link_angular_package_sync(code: String, filename: String) -> LinkResult { + use oxc_angular_compiler::linker::link; + + let allocator = Allocator::default(); + let result = link(&allocator, &code, &filename); + + LinkResult { code: result.code, map: result.map, linked: result.linked } +} + +/// Async task for Angular linking. +pub struct LinkAngularPackageTask { + code: String, + filename: String, +} + +#[napi] +impl Task for LinkAngularPackageTask { + type JsValue = LinkResult; + type Output = LinkResult; + + fn compute(&mut self) -> napi::Result { + Ok(link_angular_package_sync( + std::mem::take(&mut self.code), + std::mem::take(&mut self.filename), + )) + } + + fn resolve(&mut self, _: napi::Env, result: Self::Output) -> napi::Result { + Ok(result) + } +} + +/// Link Angular partial declarations in a JavaScript file (async). +/// +/// This is the async version of `linkAngularPackageSync`. Use this when +/// linking packages in a non-blocking context. +#[napi] +pub fn link_angular_package(code: String, filename: String) -> AsyncTask { + AsyncTask::new(LinkAngularPackageTask { code, filename }) +} diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts new file mode 100644 index 000000000..cf670b262 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -0,0 +1,97 @@ +import { readFile } from 'node:fs/promises' + +import { linkAngularPackageSync } from '#binding' +import type { Plugin } from 'vite' + +/** + * Angular Linker plugin for Vite. + * + * Processes pre-compiled Angular library code from node_modules that contains + * partial compilation declarations (ɵɵngDeclare*). These declarations need to + * be "linked" (converted to full ɵɵdefine* calls) at build time. + * + * Without this plugin, Angular falls back to JIT compilation which requires + * @angular/compiler at runtime. + * + * Uses OXC's native Rust-based linker for fast, zero-dependency linking. + * + * This plugin works in two phases: + * 1. During dependency optimization (Rolldown pre-bundling) via a Rolldown load plugin + * 2. During Vite's transform pipeline for non-optimized node_modules files + */ + +const LINKER_DECLARATION_PREFIX = '\u0275\u0275ngDeclare' + +// Skip these packages - they don't need linking +const SKIP_REGEX = /[\\/]@angular[\\/](?:compiler|core)[\\/]/ + +// Match JS files in node_modules (Angular FESM bundles) +const NODE_MODULES_JS_REGEX = /node_modules\/.*\.[cm]?js$/ + +export function angularLinkerPlugin(): Plugin { + return { + name: '@voidzero-dev/vite-plugin-angular-linker', + config() { + return { + optimizeDeps: { + rolldownOptions: { + plugins: [ + { + name: 'angular-linker', + load: { + filter: { + id: /\.[cm]?js$/, + }, + async handler(id: string) { + // Skip @angular/compiler and @angular/core + if (SKIP_REGEX.test(id)) { + return + } + + const code = await readFile(id, 'utf-8') + + // Quick check: skip files without partial declarations + if (!code.includes(LINKER_DECLARATION_PREFIX)) { + return + } + + const result = linkAngularPackageSync(code, id) + + if (!result.linked) { + return + } + + return result.code + }, + }, + }, + ], + }, + }, + } + }, + transform: { + filter: { + id: NODE_MODULES_JS_REGEX, + code: LINKER_DECLARATION_PREFIX, + }, + handler(code, id) { + // Skip packages that don't need linking + if (SKIP_REGEX.test(id)) { + return + } + + const result = linkAngularPackageSync(code, id) + + if (!result.linked) { + return + } + + return { + code: result.code, + map: result.map ?? null, + } + }, + }, + } +} diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index bde3ba585..d43599385 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -32,6 +32,7 @@ import { import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' import { jitPlugin } from './angular-jit-plugin.js' +import { angularLinkerPlugin } from './angular-linker-plugin.js' /** * Plugin options for the Angular Vite plugin. @@ -440,36 +441,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { const result = await transformAngularFile(code, actualId, transformOptions, resources) - // Debug logging for nav-base specifically - if (actualId.includes('nav-base.component')) { - console.log('[OXC Angular] nav-base.component.ts OUTPUT:') - console.log('='.repeat(80)) - console.log(result.code) - console.log('='.repeat(80)) - } - - // Debug logging for I18nPipe to see generated factory - if (actualId.includes('i18n.pipe')) { - console.log('[OXC Angular] i18n.pipe.ts OUTPUT:') - console.log('='.repeat(80)) - console.log(result.code) - console.log('='.repeat(80)) - } - - // Debug logging for services in circular dependency chain - if ( - actualId.includes('folder.service.ts') || - actualId.includes('cipher.service.ts') || - actualId.includes('api.service.ts') || - actualId.includes('jslib-services.module.ts') || - actualId.includes('core.module.ts') - ) { - console.log(`[OXC Angular] ${actualId.split('/').pop()} OUTPUT:`) - console.log('='.repeat(80)) - console.log(result.code) - console.log('='.repeat(80)) - } - // Report errors and warnings for (const error of result.errors) { this.error(error.message) @@ -601,6 +572,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { return [ angularPlugin(), stylesPlugin(), + angularLinkerPlugin(), pluginOptions.jit && jitPlugin({ inlineStylesExtension: pluginOptions.inlineStylesExtension,