From 10efc4fc7fb094237ffee4e92221d0cf34fd5809 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 15:01:00 +0800 Subject: [PATCH] fix(factory): keep string @Inject tokens in partial mode and for more string forms - Partial compilation declares the token as a string (`deps: [{ token: "API_URL" }]`), not a bare identifier that throws or does not parse. - The last `@Inject` on a parameter decides whether its token is a string, as in ngtsc. - A template literal with no substitutions, parentheses and TypeScript casts are also string tokens. - Add a strict compare fixture for a component with string tokens. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/decorator.rs | 27 ++++++--- .../src/partial/component.rs | 21 +++++-- .../tests/inject_string_token_test.rs | 60 +++++++++++++++++++ .../factory/inject-string-token.fixture.ts | 30 ++++++++++ .../e2e/compare/fixtures/known-differences.ts | 12 ++++ 5 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 napi/angular-compiler/e2e/compare/fixtures/factory/inject-string-token.fixture.ts diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 42a5ce725..cfaadd807 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -723,15 +723,12 @@ fn extract_param_dependency<'a>( // @Inject(TOKEN) - extract the token if let Expression::CallExpression(call) = &decorator.expression { if let Some(arg) = call.arguments.first() { - // `@Inject('TOKEN')`: a string token, as ngtsc compiles it - if let Argument::StringLiteral(lit) = arg - && let Some(value) = lit.value.as_str() - { - inject_token = Some(value.into()); - inject_token_is_string = true; - } else { - inject_token = extract_inject_token(arg); - } + // `@Inject('TOKEN')`: a string token, as ngtsc compiles it. + // The last `@Inject` wins, so the flag is set on every one. + let string_token = + arg.as_expression().and_then(extract_string_inject_token); + inject_token_is_string = string_token.is_some(); + inject_token = string_token.or_else(|| extract_inject_token(arg)); } } } @@ -812,6 +809,18 @@ fn extract_param_dependency<'a>( dep } +/// The value of a string `@Inject` token: a string literal, or a template literal with no +/// substitutions, also inside parentheses or a TypeScript cast. +fn extract_string_inject_token<'a>(expr: &'a Expression<'a>) -> Option> { + match expr.get_inner_expression() { + Expression::StringLiteral(lit) => lit.value.as_str().map(Ident::from), + Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { + tpl.quasis.first()?.value.cooked.as_ref()?.as_str().map(Ident::from) + } + _ => None, + } +} + /// Extract the injection token from an @Inject decorator argument. fn extract_inject_token<'a>(arg: &'a Argument<'a>) -> Option> { match arg { diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index ee9ba8497..a703e069d 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -822,7 +822,7 @@ fn ident_array_string_literals<'a>( /// Converts `component::dependency::R3DependencyMetadata` (which stores /// tokens as bare `Ident`s) into the `factory::R3DependencyMetadata` shape /// (which stores tokens as `OutputExpression`s) by wrapping each token in -/// a `ReadVar`. +/// a `ReadVar`, or a string literal for a string `@Inject` token. fn clone_factory_deps<'a>( allocator: &'a Allocator, deps: &Option>>, @@ -833,10 +833,21 @@ fn clone_factory_deps<'a>( let mut out = Vec::with_capacity_in(deps.len(), &allocator); for dep in deps { let token_expr = dep.token.as_ref().map(|t| { - OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: t.clone(), source_span: None }, - &allocator, - )) + if dep.token_is_string { + // `@Inject('TOKEN')` injects the string itself + OutputExpression::Literal(Box::new_in( + LiteralExpr { + value: LiteralValue::String(t.clone()), + source_span: None, + }, + &allocator, + )) + } else { + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: t.clone(), source_span: None }, + &allocator, + )) + } }); let attr_expr = dep.attribute_name.as_ref().map(|a| { OutputExpression::Literal(Box::new_in( diff --git a/crates/oxc_angular_compiler/tests/inject_string_token_test.rs b/crates/oxc_angular_compiler/tests/inject_string_token_test.rs index 713eea1ea..6cf7471d1 100644 --- a/crates/oxc_angular_compiler/tests/inject_string_token_test.rs +++ b/crates/oxc_angular_compiler/tests/inject_string_token_test.rs @@ -87,3 +87,63 @@ export class APipe { ); assert_contains(&code, r#"(i0.ɵɵdirectiveInject("API_URL",16))"#); } + +#[test] +fn component_string_token_forms() { + // A template literal with no substitutions, parentheses and TypeScript casts are + // still string tokens. + let code = compile( + r" +import { Component, Inject } from '@angular/core'; +@Component({ selector: 'a-cmp', template: '' }) +export class ACmp { + constructor(@Inject(`TPL`) a: string, @Inject(('PAREN')) b: string, @Inject('CAST' as any) c: string) {} +} +", + ); + assert_contains( + &code, + r#"(i0.ɵɵdirectiveInject("TPL"),i0.ɵɵdirectiveInject("PAREN"),i0.ɵɵdirectiveInject("CAST"))"#, + ); +} + +#[test] +fn component_last_inject_wins() { + // ngtsc uses the last `@Inject`, so a class token after a string one is not a string. + let code = compile( + r" +import { Component, Inject } from '@angular/core'; +class Svc {} +@Component({ selector: 'a-cmp', template: '' }) +export class ACmp { + constructor(@Inject('dup') @Inject(Svc) a: any) {} +} +", + ); + assert_contains(&code, r"(i0.ɵɵdirectiveInject(Svc))"); +} + +#[test] +fn partial_component_factory_declares_the_string() { + let allocator = Allocator::default(); + let options = TransformOptions { + compilation_mode: oxc_angular_compiler::CompilationMode::Partial, + ..TransformOptions::default() + }; + let result = transform_angular_file( + &allocator, + "test.ts", + r#" +import { Component, Inject, Optional } from '@angular/core'; +@Component({ selector: 'a-cmp', template: '' }) +export class ACmp { + constructor(@Inject('API_URL') url: string, @Optional() @Inject("it's") b: string) {} +} +"#, + Some(&options), + None, + ); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert_contains(&code, r#"deps:[{token:"API_URL"},{token:"it's",optional:true}]"#); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/factory/inject-string-token.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/factory/inject-string-token.fixture.ts new file mode 100644 index 000000000..eccd01cd8 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/factory/inject-string-token.fixture.ts @@ -0,0 +1,30 @@ +/** + * `@Inject('TOKEN')` with a string token on a component constructor. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'component-inject-string-token', + category: 'factory', + description: 'component factory injects string tokens, with flags', + className: 'InjectStringTokenComponent', + type: 'full-transform', + sourceCode: ` +import { Component, Inject, Optional, Self } from '@angular/core'; + +@Component({ + selector: 'app-inject-string-token', + template: '', +}) +export class InjectStringTokenComponent { + constructor( + @Inject('API_URL') url: string, + @Optional() @Self() @Inject('FLAGS') flags: string[], + @Inject(('PAREN')) paren: string, + ) {} +} + `.trim(), + expectedFeatures: ['ɵɵdirectiveInject'], + }, +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 9ab09e8f1..d99d4b084 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -113,6 +113,18 @@ export const KNOWN_DIFFERENCES: Record = { ], reasons: [SET_CLASS_METADATA_IMPORT], }, + 'factory/component-inject-string-token': { + fields: [], + importDiffs: [ + { + type: 'different', + moduleSource: '@angular/core', + expected: ['Component'], + actual: ['Component', 'Inject', 'Optional', 'Self'], + }, + ], + reasons: [SET_CLASS_METADATA_IMPORT], + }, 'full-file/component-with-services': { fields: [], importDiffs: [