Skip to content

Commit 734aa9c

Browse files
Brooooooklynclaude
andauthored
chore: remove jit (#49)
fix(compiler): remove JIT, handle template literals, preserve switch order, rename field to formField - Remove `@angular/compiler` JIT import from playground and e2e app - Fix linker's get_string_property() to handle TemplateLiteral (Angular 21 emits some templates as backtick literals, e.g. `<router-outlet />`), which caused ɵɵngDeclareComponent to be silently skipped for ɵEmptyOutletComponent - Remove incorrect @default reordering in ingest_switch_block() — Angular TS preserves source order and conditionals.rs already handles @default at any position - Rename "field" to "formField" in ingest.rs and binding_specialization.rs to match Angular's Jan 2026 rename of the signal forms directive - Update vite linker plugin to use this.fs.readFile instead of node:fs - Bump Angular deps to 21.2.0/20.3.17 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b296ec2 commit 734aa9c

File tree

11 files changed

+806
-1367
lines changed

11 files changed

+806
-1367
lines changed

crates/oxc_angular_compiler/src/linker/mod.rs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,12 +369,23 @@ fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExp
369369
}
370370

371371
/// Extract a string property value from an object expression.
372+
/// Handles both regular string literals (`"..."`) and template literals with no expressions (`` `...` ``).
372373
fn get_string_property<'a>(obj: &'a ObjectExpression<'a>, name: &str) -> Option<&'a str> {
373374
for prop in &obj.properties {
374375
if let ObjectPropertyKind::ObjectProperty(prop) = prop {
375376
if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) {
376-
if let Expression::StringLiteral(lit) = &prop.value {
377-
return Some(lit.value.as_str());
377+
match &prop.value {
378+
Expression::StringLiteral(lit) => {
379+
return Some(lit.value.as_str());
380+
}
381+
Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => {
382+
if let Some(quasi) = tl.quasis.first() {
383+
if let Some(cooked) = &quasi.value.cooked {
384+
return Some(cooked.as_str());
385+
}
386+
}
387+
}
388+
_ => {}
378389
}
379390
}
380391
}
@@ -1934,4 +1945,82 @@ MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.
19341945
);
19351946
assert!(!result.code.contains("null]"), "Should not include null transform in output");
19361947
}
1948+
1949+
#[test]
1950+
fn test_link_component_with_template_literal() {
1951+
let allocator = Allocator::default();
1952+
let code = r#"
1953+
import * as i0 from "@angular/core";
1954+
class MyComponent {
1955+
}
1956+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.6", ngImport: i0, type: MyComponent, selector: "my-comp", template: `<div>Hello</div>`, isInline: true });
1957+
"#;
1958+
let result = link(&allocator, code, "test.mjs");
1959+
assert!(result.linked, "Component with template literal should be linked");
1960+
assert!(
1961+
result.code.contains("defineComponent"),
1962+
"Should contain defineComponent, got:\n{}",
1963+
result.code
1964+
);
1965+
assert!(
1966+
!result.code.contains("\u{0275}\u{0275}ngDeclareComponent"),
1967+
"Should not contain ngDeclareComponent, got:\n{}",
1968+
result.code
1969+
);
1970+
}
1971+
1972+
#[test]
1973+
fn test_link_component_with_template_literal_static_field() {
1974+
let allocator = Allocator::default();
1975+
// This matches Angular 21's actual output format for @angular/router's ɵEmptyOutletComponent
1976+
let code = r#"
1977+
import * as i0 from "@angular/core";
1978+
class EmptyOutletComponent {
1979+
static ɵfac = i0.ɵɵngDeclareFactory({
1980+
minVersion: "12.0.0",
1981+
version: "21.0.6",
1982+
ngImport: i0,
1983+
type: EmptyOutletComponent,
1984+
deps: [],
1985+
target: i0.ɵɵFactoryTarget.Component
1986+
});
1987+
static ɵcmp = i0.ɵɵngDeclareComponent({
1988+
minVersion: "14.0.0",
1989+
version: "21.0.6",
1990+
type: EmptyOutletComponent,
1991+
isStandalone: true,
1992+
selector: "ng-component",
1993+
exportAs: ["emptyRouterOutlet"],
1994+
ngImport: i0,
1995+
template: `<router-outlet />`,
1996+
isInline: true,
1997+
dependencies: [{
1998+
kind: "directive",
1999+
type: RouterOutlet,
2000+
selector: "router-outlet",
2001+
inputs: ["name", "routerOutletData"],
2002+
outputs: ["activate", "deactivate", "attach", "detach"],
2003+
exportAs: ["outlet"]
2004+
}]
2005+
});
2006+
}
2007+
"#;
2008+
let result = link(&allocator, code, "test.mjs");
2009+
assert!(result.linked, "Component with template literal in static field should be linked");
2010+
assert!(
2011+
result.code.contains("defineComponent"),
2012+
"Should contain defineComponent, got:\n{}",
2013+
result.code
2014+
);
2015+
assert!(
2016+
!result.code.contains("\u{0275}\u{0275}ngDeclareComponent"),
2017+
"Should not contain ngDeclareComponent, got:\n{}",
2018+
result.code
2019+
);
2020+
assert!(
2021+
result.code.contains("dependencies: [RouterOutlet]"),
2022+
"Should extract dependency types, got:\n{}",
2023+
result.code
2024+
);
2025+
}
19372026
}

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,14 +1031,14 @@ fn ingest_element<'a>(
10311031
// Process local references
10321032
let local_refs = ingest_references_owned(allocator, element.references);
10331033

1034-
// Check for field property binding to create ControlCreateOp.
1034+
// Check for formField property binding to create ControlCreateOp.
10351035
// This matches TypeScript's ingest.ts which checks:
10361036
// const fieldInput = element.inputs.find(
1037-
// (input) => input.name === 'field' && input.type === e.BindingType.Property
1037+
// (input) => input.name === 'formField' && input.type === e.BindingType.Property
10381038
// );
10391039
use crate::ast::expression::BindingType;
10401040
let field_input_span = element.inputs.iter().find_map(|input| {
1041-
if input.name.as_str() == "field" && input.binding_type == BindingType::Property {
1041+
if input.name.as_str() == "formField" && input.binding_type == BindingType::Property {
10421042
Some(input.source_span)
10431043
} else {
10441044
None
@@ -2922,26 +2922,14 @@ fn ingest_switch_block<'a>(
29222922
// Convert the main switch expression as the test
29232923
let test = convert_ast_to_ir(job, switch_block.expression);
29242924

2925-
// Reorder groups to put @default LAST, matching Angular's compiled output.
2926-
// While Angular's ingestSwitchBlock iterates in source order, the downstream
2927-
// generateConditionalExpressions phase (conditionals.ts) splices @default out and
2928-
// uses it as the ternary fallback base. Because slot allocation and function naming
2929-
// happen after ingest, moving @default last here ensures our xref/slot/function
2930-
// ordering matches Angular's final output.
2931-
let mut groups_vec: std::vec::Vec<_> = switch_block.groups.into_iter().collect();
2932-
let default_idx = groups_vec.iter().position(|group| {
2933-
!group.cases.is_empty() && group.cases.iter().all(|c| c.expression.is_none())
2934-
});
2935-
if let Some(idx) = default_idx {
2936-
let default_group = groups_vec.remove(idx);
2937-
groups_vec.push(default_group);
2938-
}
2939-
2925+
// Iterate groups in source order, matching Angular TS's ingestSwitchBlock.
2926+
// The downstream generate_conditional_expressions phase handles @default at
2927+
// any position by splicing it out as the ternary fallback base.
29402928
let mut first_xref: Option<XrefId> = None;
29412929
let mut conditions: Vec<'a, ConditionalCaseExpr<'a>> = Vec::new_in(allocator);
29422930
let mut create_ops: std::vec::Vec<CreateOp<'a>> = std::vec::Vec::new();
29432931

2944-
for (i, group) in groups_vec.into_iter().enumerate() {
2932+
for (i, group) in switch_block.groups.into_iter().enumerate() {
29452933
// Allocate a new view for this group
29462934
let group_view_xref = job.allocate_view(Some(view_xref));
29472935

crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ fn specialize_in_view<'a>(
300300
});
301301
cursor.replace_current(new_op);
302302
}
303-
} else if name.as_str() == "field" {
304-
// Check for special "field" property (control binding)
303+
} else if name.as_str() == "formField" {
304+
// Check for special "formField" property (control binding)
305305
if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() {
306306
let expression = std::mem::replace(
307307
&mut binding.expression,

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3054,17 +3054,17 @@ fn test_svg_in_switch_case_with_whitespace() {
30543054

30553055
#[test]
30563056
fn test_control_binding_attribute_extraction() {
3057-
// Test that [field] (control binding) is extracted into the consts array.
3057+
// Test that [formField] (control binding) is extracted into the consts array.
30583058
// Before the fix, UpdateOp::Control was not handled in attribute extraction,
3059-
// causing the control binding name ("field") to be missing from the element's
3059+
// causing the control binding name ("formField") to be missing from the element's
30603060
// extracted attributes. This resulted in duplicate/shifted const entries.
30613061
let allocator = Allocator::default();
30623062
let source = r#"
30633063
import { Component } from '@angular/core';
30643064
30653065
@Component({
30663066
selector: 'test-comp',
3067-
template: '<cu-comp [field]="myField" [open]="isOpen"></cu-comp>',
3067+
template: '<cu-comp [formField]="myField" [open]="isOpen"></cu-comp>',
30683068
standalone: true,
30693069
})
30703070
export class TestComponent {
@@ -3084,24 +3084,26 @@ export class TestComponent {
30843084
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
30853085
eprintln!("OUTPUT:\n{}", result.code);
30863086

3087-
// The consts array should contain "field" as an extracted property binding name.
3088-
// Without the fix, only "open" would appear (missing "field"), resulting in
3087+
// The consts array should contain "formField" as an extracted property binding name.
3088+
// Without the fix, only "open" would appear (missing "formField"), resulting in
30893089
// incorrect const array entries and shifted indices.
30903090
assert!(
3091-
result.code.contains(r#""field""#),
3092-
"Consts should contain 'field' from control binding extraction. Output:\n{}",
3091+
result.code.contains(r#""formField""#),
3092+
"Consts should contain 'formField' from control binding extraction. Output:\n{}",
30933093
result.code
30943094
);
30953095

3096-
// Both "field" and "open" should appear in the same consts entry (same element).
3096+
// Both "formField" and "open" should appear in the same consts entry (same element).
30973097
// The property marker (3) should precede both names.
3098-
// Expected: [3, "field", "open"] (property marker followed by both binding names)
3099-
// Without the fix: [3, "open"] (missing "field")
3100-
let has_both_in_same_const =
3101-
result.code.lines().any(|line| line.contains(r#""field""#) && line.contains(r#""open""#));
3098+
// Expected: [3, "formField", "open"] (property marker followed by both binding names)
3099+
// Without the fix: [3, "open"] (missing "formField")
3100+
let has_both_in_same_const = result
3101+
.code
3102+
.lines()
3103+
.any(|line| line.contains(r#""formField""#) && line.contains(r#""open""#));
31023104
assert!(
31033105
has_both_in_same_const,
3104-
"Both 'field' and 'open' should appear in the same consts entry. Output:\n{}",
3106+
"Both 'formField' and 'open' should appear in the same consts entry. Output:\n{}",
31053107
result.code
31063108
);
31073109
}
@@ -3125,7 +3127,7 @@ fn test_pipe_slot_in_control_binding_exact_slot() {
31253127
// Element is at slot 0, pipe is at slot 1.
31263128
// The pipeBind1 call should reference slot 1, not slot 0.
31273129
let js = compile_template_to_js(
3128-
r#"<cu-comp [field]="myField$ | async"></cu-comp>"#,
3130+
r#"<cu-comp [formField]="myField$ | async"></cu-comp>"#,
31293131
"TestComponent",
31303132
);
31313133
eprintln!("OUTPUT:\n{js}");
@@ -3147,7 +3149,7 @@ fn test_pipe_slot_in_control_binding_exact_slot() {
31473149
#[test]
31483150
fn test_pipe_in_field_binding_with_safe_nav() {
31493151
let js = compile_template_to_js(
3150-
r#"<cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>"#,
3152+
r#"<cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>"#,
31513153
"TestComponent",
31523154
);
31533155
eprintln!("OUTPUT:\n{js}");
@@ -3163,7 +3165,7 @@ fn test_pipe_in_field_binding_with_safe_nav() {
31633165
#[test]
31643166
fn test_pipe_in_field_in_ngif() {
31653167
let js = compile_template_to_js(
3166-
r#"<div *ngIf="show"><cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp></div>"#,
3168+
r#"<div *ngIf="show"><cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp></div>"#,
31673169
"TestComponent",
31683170
);
31693171
eprintln!("OUTPUT:\n{js}");
@@ -3174,7 +3176,7 @@ fn test_pipe_in_field_in_ngif() {
31743176
#[test]
31753177
fn test_pipe_in_field_in_if_block() {
31763178
let js = compile_template_to_js(
3177-
r#"@if (show) {<cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>}"#,
3179+
r#"@if (show) {<cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>}"#,
31783180
"TestComponent",
31793181
);
31803182
eprintln!("OUTPUT:\n{js}");
@@ -5376,3 +5378,62 @@ fn test_if_block_no_expression_skips_main_branch() {
53765378
}
53775379
assert!(!errors.is_empty(), "Should report a parse error for @if without expression");
53785380
}
5381+
5382+
// ============================================================================
5383+
// Regression: @switch with @default first should preserve source order
5384+
// ============================================================================
5385+
5386+
#[test]
5387+
fn test_switch_default_first_preserves_source_order() {
5388+
// When @default appears first in source, Angular TS preserves source order:
5389+
// Case_0 = default (Other), Case_1 = case(1) (One), Case_2 = case(2) (Two)
5390+
// The conditional expression puts default's slot as the ternary fallback.
5391+
let js = compile_template_to_js(
5392+
r"@switch (value) { @default { <div>Other</div> } @case (1) { <div>One</div> } @case (2) { <div>Two</div> } }",
5393+
"TestComponent",
5394+
);
5395+
5396+
// Case_0 should be the default (Other), NOT reordered
5397+
assert!(js.contains("Case_0_Template"), "Expected Case_0_Template in output. Got:\n{}", js);
5398+
let case0_start = js.find("Case_0_Template").unwrap();
5399+
let case0_body = &js[case0_start..case0_start + 200];
5400+
assert!(
5401+
case0_body.contains("Other"),
5402+
"Case_0 should render 'Other' (default in source order). Got:\n{}",
5403+
js
5404+
);
5405+
5406+
// Conditional ternary: default slot (0) should be the fallback base
5407+
// Expected: (tmp === 1) ? 1 : (tmp === 2) ? 2 : 0
5408+
assert!(js.contains("2: 0)"), "Ternary fallback should be slot 0 (default). Got:\n{}", js);
5409+
}
5410+
5411+
// ============================================================================
5412+
// Regression: [field] should be a regular property, not a control binding
5413+
// ============================================================================
5414+
5415+
#[test]
5416+
fn test_field_property_not_control_binding() {
5417+
// [field] is a regular property binding, NOT a form control binding.
5418+
// Only [formField] should trigger control binding behavior.
5419+
// Before fix: [field] emitted controlCreate()/control() instructions.
5420+
// After fix: [field] emits regular property() instruction.
5421+
let js = compile_template_to_js(r#"<cu-comp [field]="myField"></cu-comp>"#, "TestComponent");
5422+
5423+
// Should NOT have controlCreate
5424+
assert!(
5425+
!js.contains("controlCreate"),
5426+
"[field] should NOT produce controlCreate. Got:\n{}",
5427+
js
5428+
);
5429+
5430+
// Should NOT have control() call
5431+
assert!(!js.contains("ɵɵcontrol("), "[field] should NOT produce ɵɵcontrol(). Got:\n{}", js);
5432+
5433+
// Should have regular property binding
5434+
assert!(
5435+
js.contains(r#"ɵɵproperty("field""#),
5436+
"[field] should produce regular ɵɵproperty(\"field\", ...). Got:\n{}",
5437+
js
5438+
);
5439+
}

crates/oxc_angular_compiler/tests/snapshots/integration_test__switch_block_default_first.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ function TestComponent_Case_0_Template(rf,ctx) {
66
if ((rf & 1)) {
77
i0.ɵɵtext(0," ");
88
i0.ɵɵelementStart(1,"div");
9-
i0.ɵɵtext(2,"One");
9+
i0.ɵɵtext(2,"Other");
1010
i0.ɵɵelementEnd();
1111
i0.ɵɵtext(3," ");
1212
}
@@ -15,7 +15,7 @@ function TestComponent_Case_1_Template(rf,ctx) {
1515
if ((rf & 1)) {
1616
i0.ɵɵtext(0," ");
1717
i0.ɵɵelementStart(1,"div");
18-
i0.ɵɵtext(2,"Two");
18+
i0.ɵɵtext(2,"One");
1919
i0.ɵɵelementEnd();
2020
i0.ɵɵtext(3," ");
2121
}
@@ -24,7 +24,7 @@ function TestComponent_Case_2_Template(rf,ctx) {
2424
if ((rf & 1)) {
2525
i0.ɵɵtext(0," ");
2626
i0.ɵɵelementStart(1,"div");
27-
i0.ɵɵtext(2,"Other");
27+
i0.ɵɵtext(2,"Two");
2828
i0.ɵɵelementEnd();
2929
i0.ɵɵtext(3," ");
3030
}
@@ -34,6 +34,6 @@ function TestComponent_Template(rf,ctx) {
3434
4,0)(2,TestComponent_Case_2_Template,4,0); }
3535
if ((rf & 2)) {
3636
let tmp_0_0;
37-
i0.ɵɵconditional((((tmp_0_0 = ctx.value) === 1)? 0: ((tmp_0_0 === 2)? 1: 2)));
37+
i0.ɵɵconditional((((tmp_0_0 = ctx.value) === 1)? 1: ((tmp_0_0 === 2)? 2: 0)));
3838
}
3939
}

napi/angular-compiler/benchmarks/bitwarden/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
"benchmark:incremental": "oxnode benchmark.ts --incremental"
1414
},
1515
"dependencies": {
16-
"@angular/animations": "20.3.15",
16+
"@angular/animations": "20.3.17",
1717
"@angular/cdk": "20.2.14",
18-
"@angular/common": "20.3.15",
19-
"@angular/compiler": "20.3.15",
18+
"@angular/common": "20.3.17",
19+
"@angular/compiler": "20.3.17",
2020
"@angular/core": "20.3.17",
21-
"@angular/forms": "20.3.15",
22-
"@angular/platform-browser": "20.3.15",
23-
"@angular/platform-browser-dynamic": "20.3.15",
24-
"@angular/router": "20.3.15",
21+
"@angular/forms": "20.3.17",
22+
"@angular/platform-browser": "20.3.17",
23+
"@angular/platform-browser-dynamic": "20.3.17",
24+
"@angular/router": "20.3.17",
2525
"core-js": "^3.47.0",
2626
"rxjs": "~7.8.0",
2727
"tslib": "^2.8.1",

napi/angular-compiler/e2e/app/src/main.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import '@angular/compiler'
21
import { bootstrapApplication } from '@angular/platform-browser'
32

43
import { App } from './app/app.component'

0 commit comments

Comments
 (0)