Skip to content

Commit 3a4f3ea

Browse files
feat(transform): support @service decorator in JIT downlevel transform (#311) (#320)
* feat(transform): support @service decorator in JIT downlevel transform (#311) Recognize Angular v22's @service decorator in the JIT downlevel pipeline so the runtime JIT facade (compileService) can rediscover it. A @service class is now restructured the same way as @Injectable: decorator removed, static decorators/ctorParameters emitted, and lowered via __decorate(...). - Add `Service` variant to `AngularDecoratorKind` and match it in `find_angular_decorator`; include `"Service"` in `ANGULAR_DECORATOR_NAMES`. - Add `AngularVersion::supports_service_decorator()` (v22+) helper. - Version-gate: when targeting Angular < 22, emit a diagnostic and leave the @service decorator unchanged (pass-through), since the runtime lacks compileService. Unknown version defaults to "supports". - Tests: @service downlevels like @Injectable (snapshot); targeting v21 surfaces a diagnostic and leaves the decorator in source. https://claude.ai/code/session_01U6t6qXHNPsEUk5ZFEcB8NQ * fix(jit): skip non-Angular @service decorators when scanning for class kind A bare `@Service()` matched by raw name only would shadow a real Angular decorator on the same class. On pre-v22 targets the version gate would then emit a misleading "requires v22" diagnostic and `continue` past the sibling `@Component`/`@Injectable`, leaving it unlowered. Thread the import map into `find_angular_decorator` and require the `Service` identifier to resolve to `@angular/core`. Other Angular decorator names are left as raw-name matches to preserve namespace-style usage like `@core.Component()`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bff6a42 commit 3a4f3ea

4 files changed

Lines changed: 231 additions & 8 deletions

File tree

crates/oxc_angular_compiler/src/component/metadata.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ impl AngularVersion {
6363
self.major >= 20
6464
}
6565

66+
/// Check if this version supports the `@Service` decorator (v22.0.0+).
67+
///
68+
/// Angular v22 introduced the `@Service` decorator as a lighter alternative to
69+
/// `@Injectable`. The runtime gains `compileService`/`ɵɵdefineService` in the same
70+
/// release, so earlier versions cannot consume downleveled `@Service` metadata.
71+
pub fn supports_service_decorator(&self) -> bool {
72+
self.major >= 22
73+
}
74+
6675
/// Parse a version string like "19.0.0" or "19.0.0-rc.1".
6776
///
6877
/// Returns `None` if the version string is invalid.

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ enum AngularDecoratorKind {
795795
Directive,
796796
Pipe,
797797
Injectable,
798+
Service,
798799
NgModule,
799800
}
800801

@@ -864,8 +865,20 @@ struct JitNonAngularMemberDecorator {
864865
}
865866

866867
/// Find any Angular decorator on a class and return its kind and the decorator reference.
868+
///
869+
/// For the `Service` identifier specifically, the import map is consulted so a
870+
/// bare `@Service()` from a non-Angular library doesn't shadow a real Angular
871+
/// decorator that follows it on the same class. `Service` is common enough as a
872+
/// library export name (DI containers, web frameworks) that name-only matching
873+
/// would cause the JIT pipeline to either misclassify the class or, on
874+
/// pre-v22 targets, emit a misleading diagnostic and skip the sibling
875+
/// `@Component`/`@Injectable`/etc. Other Angular decorator names are unique
876+
/// enough in practice that the same check isn't applied to them — and doing so
877+
/// would regress namespace-style usage like `@core.Component()` where the
878+
/// identifier isn't directly in the import map.
867879
fn find_angular_decorator<'a>(
868880
class: &'a oxc_ast::ast::Class<'a>,
881+
import_map: &ImportMap<'a>,
869882
) -> Option<(AngularDecoratorKind, &'a oxc_ast::ast::Decorator<'a>)> {
870883
for decorator in &class.decorators {
871884
if let Expression::CallExpression(call) = &decorator.expression {
@@ -874,13 +887,30 @@ fn find_angular_decorator<'a>(
874887
Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
875888
_ => None,
876889
};
877-
match name {
878-
Some("Component") => return Some((AngularDecoratorKind::Component, decorator)),
879-
Some("Directive") => return Some((AngularDecoratorKind::Directive, decorator)),
880-
Some("Pipe") => return Some((AngularDecoratorKind::Pipe, decorator)),
881-
Some("Injectable") => return Some((AngularDecoratorKind::Injectable, decorator)),
882-
Some("NgModule") => return Some((AngularDecoratorKind::NgModule, decorator)),
883-
_ => {}
890+
let kind = match name {
891+
Some("Component") => Some(AngularDecoratorKind::Component),
892+
Some("Directive") => Some(AngularDecoratorKind::Directive),
893+
Some("Pipe") => Some(AngularDecoratorKind::Pipe),
894+
Some("Injectable") => Some(AngularDecoratorKind::Injectable),
895+
Some("Service") => Some(AngularDecoratorKind::Service),
896+
Some("NgModule") => Some(AngularDecoratorKind::NgModule),
897+
_ => None,
898+
};
899+
900+
if matches!(kind, Some(AngularDecoratorKind::Service)) {
901+
if let Expression::Identifier(id) = &call.callee {
902+
let info = import_map.get(&Ident::from(id.name.as_str()));
903+
let from_angular_core = info
904+
.map(|info| info.source_module.as_str() == "@angular/core")
905+
.unwrap_or(false);
906+
if !from_angular_core {
907+
continue;
908+
}
909+
}
910+
}
911+
912+
if let Some(k) = kind {
913+
return Some((k, decorator));
884914
}
885915
}
886916
}
@@ -995,6 +1025,7 @@ const ANGULAR_DECORATOR_NAMES: &[&str] = &[
9951025
"Directive",
9961026
"Pipe",
9971027
"Injectable",
1028+
"Service",
9981029
"NgModule",
9991030
];
10001031

@@ -1826,6 +1857,12 @@ fn transform_angular_file_jit(
18261857
// / `styleUrls`, matching the AOT metadata extraction path.
18271858
let string_consts = collect_string_consts(allocator, &parser_ret.program);
18281859

1860+
// Build an import map so `find_angular_decorator` can verify that a bare
1861+
// `@Service()` is actually Angular's, not a same-named decorator from
1862+
// another library.
1863+
let import_map =
1864+
build_import_map(allocator, &parser_ret.program.body, options.resolved_imports.as_ref());
1865+
18291866
// 3. Walk AST to find Angular-decorated classes
18301867
let mut jit_classes: std::vec::Vec<JitClassInfo> = std::vec::Vec::new();
18311868
let mut resource_counter: u32 = 0;
@@ -1856,10 +1893,26 @@ fn transform_angular_file_jit(
18561893
continue;
18571894
};
18581895

1859-
let Some((decorator_kind, angular_decorator)) = find_angular_decorator(class) else {
1896+
let Some((decorator_kind, angular_decorator)) = find_angular_decorator(class, &import_map)
1897+
else {
18601898
continue;
18611899
};
18621900

1901+
// Version gating: the `@Service` decorator requires Angular v22+, where the
1902+
// runtime JIT facade gained `compileService`/`ɵɵdefineService`. When targeting an
1903+
// older version, surface the gap and leave the decorator unchanged (pass-through)
1904+
// so the JIT downlevel pipeline doesn't emit metadata the runtime can't consume.
1905+
// When the version is unknown, assume support (matches the `map_or(true, …)` pattern).
1906+
if matches!(decorator_kind, AngularDecoratorKind::Service)
1907+
&& !options.angular_version.map_or(true, |v| v.supports_service_decorator())
1908+
{
1909+
result.diagnostics.push(OxcDiagnostic::error(format!(
1910+
"The @Service decorator on '{}' requires Angular v22 or later.",
1911+
class_name
1912+
)));
1913+
continue;
1914+
}
1915+
18631916
// Collect ALL class-level decorator spans and texts (in source order)
18641917
let mut all_class_decorator_spans: std::vec::Vec<Span> = std::vec::Vec::new();
18651918
let mut all_class_decorator_texts: std::vec::Vec<String> = std::vec::Vec::new();

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6864,6 +6864,153 @@ export class HighlightDirective {
68646864
insta::assert_snapshot!("jit_directive", result.code);
68656865
}
68666866

6867+
#[test]
6868+
fn test_jit_service_decorator() {
6869+
// @Service (Angular v22+) should be JIT-downleveled exactly like @Injectable:
6870+
// decorator removed, static decorators/ctorParameters emitted, and __decorate applied.
6871+
let allocator = Allocator::default();
6872+
let source = r"
6873+
import { Service } from '@angular/core';
6874+
6875+
@Service()
6876+
export class CounterService {
6877+
constructor(private http: HttpClient) {}
6878+
}
6879+
";
6880+
6881+
let options = ComponentTransformOptions { jit: true, ..Default::default() };
6882+
let result =
6883+
transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None);
6884+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
6885+
6886+
// The @Service decorator should be removed and lowered through __decorate.
6887+
assert!(
6888+
!result.code.contains("@Service"),
6889+
"JIT output should NOT contain the @Service decorator. Got:\n{}",
6890+
result.code
6891+
);
6892+
assert!(
6893+
result.code.contains("__decorate("),
6894+
"JIT service output should use __decorate. Got:\n{}",
6895+
result.code
6896+
);
6897+
6898+
// ctorParameters reflecting the constructor dependency should be emitted.
6899+
assert!(
6900+
result.code.contains("ctorParameters") && result.code.contains("HttpClient"),
6901+
"JIT service output should emit ctorParameters with HttpClient. Got:\n{}",
6902+
result.code
6903+
);
6904+
6905+
// JIT must NOT emit AOT definitions; the runtime's compileService handles that.
6906+
assert!(
6907+
!result.code.contains("ɵsvc") && !result.code.contains("ɵfac"),
6908+
"JIT service output should NOT contain AOT definitions. Got:\n{}",
6909+
result.code
6910+
);
6911+
6912+
insta::assert_snapshot!("jit_service_decorator", result.code);
6913+
}
6914+
6915+
#[test]
6916+
fn test_jit_service_decorator_version_gated() {
6917+
// Targeting Angular < 22 must not downlevel @Service (the runtime lacks
6918+
// compileService). The decorator is left in source and a diagnostic is surfaced.
6919+
let allocator = Allocator::default();
6920+
let source = r"
6921+
import { Service } from '@angular/core';
6922+
6923+
@Service()
6924+
export class CounterService {}
6925+
";
6926+
6927+
let options = ComponentTransformOptions {
6928+
jit: true,
6929+
angular_version: Some(AngularVersion::new(21, 0, 0)),
6930+
..Default::default()
6931+
};
6932+
let result =
6933+
transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None);
6934+
6935+
// A diagnostic should be surfaced for the unsupported decorator.
6936+
assert!(
6937+
result.has_errors(),
6938+
"Targeting v21 with @Service should produce a diagnostic. Got none.\n{}",
6939+
result.code
6940+
);
6941+
assert!(
6942+
result
6943+
.diagnostics
6944+
.iter()
6945+
.any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")),
6946+
"Diagnostic should mention @Service and v22. Got: {:?}",
6947+
result.diagnostics
6948+
);
6949+
6950+
// The decorator should remain in the source (pass-through, not downleveled).
6951+
assert!(
6952+
result.code.contains("@Service"),
6953+
"JIT output for v21 should leave the @Service decorator unchanged. Got:\n{}",
6954+
result.code
6955+
);
6956+
assert!(
6957+
!result.code.contains("__decorate("),
6958+
"JIT output for v21 should NOT downlevel @Service via __decorate. Got:\n{}",
6959+
result.code
6960+
);
6961+
}
6962+
6963+
#[test]
6964+
fn test_jit_non_angular_service_decorator_does_not_shadow_injectable() {
6965+
// A `@Service()` decorator from a non-Angular library must not cause the JIT
6966+
// pipeline to misclassify the class as v22 `@Service`, nor (on pre-v22
6967+
// targets) swallow a sibling `@Injectable` via the version-gate's early
6968+
// `continue`. Name matching alone is insufficient — `Service` is a common
6969+
// export name in DI containers and web frameworks.
6970+
let allocator = Allocator::default();
6971+
let source = r"
6972+
import { Injectable } from '@angular/core';
6973+
import { Service } from 'some-other-lib';
6974+
6975+
@Service()
6976+
@Injectable()
6977+
export class CounterService {
6978+
constructor(private http: HttpClient) {}
6979+
}
6980+
";
6981+
6982+
let options = ComponentTransformOptions {
6983+
jit: true,
6984+
angular_version: Some(AngularVersion::new(21, 0, 0)),
6985+
..Default::default()
6986+
};
6987+
let result =
6988+
transform_angular_file(&allocator, "counter.service.ts", source, Some(&options), None);
6989+
6990+
// No "@Service requires v22" diagnostic should fire — this isn't Angular's
6991+
// Service.
6992+
assert!(
6993+
!result
6994+
.diagnostics
6995+
.iter()
6996+
.any(|d| d.to_string().contains("@Service") && d.to_string().contains("v22")),
6997+
"Non-Angular @Service should not trigger the v22 diagnostic. Got: {:?}",
6998+
result.diagnostics
6999+
);
7000+
7001+
// The real @Injectable must still be lowered.
7002+
assert!(
7003+
result.code.contains("__decorate("),
7004+
"JIT output should still lower @Injectable via __decorate. Got:\n{}",
7005+
result.code
7006+
);
7007+
assert!(
7008+
result.code.contains("ctorParameters") && result.code.contains("HttpClient"),
7009+
"JIT output should emit ctorParameters for the lowered @Injectable. Got:\n{}",
7010+
result.code
7011+
);
7012+
}
7013+
68677014
#[test]
68687015
fn test_jit_full_component_example() {
68697016
// Full example matching the issue #97 scenario
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: result.code
4+
---
5+
import { Service } from "@angular/core";
6+
import { __decorate } from "tslib";
7+
let CounterService = class CounterService {
8+
constructor(http) {
9+
this.http = http;
10+
}
11+
static ctorParameters = () => [{ type: HttpClient }];
12+
};
13+
CounterService = __decorate([Service()], CounterService);
14+
export { CounterService };

0 commit comments

Comments
 (0)