Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 91 additions & 2 deletions crates/oxc_angular_compiler/src/linker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,23 @@ fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExp
}

/// Extract a string property value from an object expression.
/// Handles both regular string literals (`"..."`) and template literals with no expressions (`` `...` ``).
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());
match &prop.value {
Expression::StringLiteral(lit) => {
return Some(lit.value.as_str());
}
Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => {
if let Some(quasi) = tl.quasis.first() {
if let Some(cooked) = &quasi.value.cooked {
return Some(cooked.as_str());
}
}
}
_ => {}
}
}
}
Expand Down Expand Up @@ -1934,4 +1945,82 @@ MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.
);
assert!(!result.code.contains("null]"), "Should not include null transform in output");
}

#[test]
fn test_link_component_with_template_literal() {
let allocator = Allocator::default();
let code = r#"
import * as i0 from "@angular/core";
class MyComponent {
}
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 });
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked, "Component with template literal should be linked");
assert!(
result.code.contains("defineComponent"),
"Should contain defineComponent, got:\n{}",
result.code
);
assert!(
!result.code.contains("\u{0275}\u{0275}ngDeclareComponent"),
"Should not contain ngDeclareComponent, got:\n{}",
result.code
);
}

#[test]
fn test_link_component_with_template_literal_static_field() {
let allocator = Allocator::default();
// This matches Angular 21's actual output format for @angular/router's ɵEmptyOutletComponent
let code = r#"
import * as i0 from "@angular/core";
class EmptyOutletComponent {
static ɵfac = i0.ɵɵngDeclareFactory({
minVersion: "12.0.0",
version: "21.0.6",
ngImport: i0,
type: EmptyOutletComponent,
deps: [],
target: i0.ɵɵFactoryTarget.Component
});
static ɵcmp = i0.ɵɵngDeclareComponent({
minVersion: "14.0.0",
version: "21.0.6",
type: EmptyOutletComponent,
isStandalone: true,
selector: "ng-component",
exportAs: ["emptyRouterOutlet"],
ngImport: i0,
template: `<router-outlet />`,
isInline: true,
dependencies: [{
kind: "directive",
type: RouterOutlet,
selector: "router-outlet",
inputs: ["name", "routerOutletData"],
outputs: ["activate", "deactivate", "attach", "detach"],
exportAs: ["outlet"]
}]
});
}
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked, "Component with template literal in static field should be linked");
assert!(
result.code.contains("defineComponent"),
"Should contain defineComponent, got:\n{}",
result.code
);
assert!(
!result.code.contains("\u{0275}\u{0275}ngDeclareComponent"),
"Should not contain ngDeclareComponent, got:\n{}",
result.code
);
assert!(
result.code.contains("dependencies: [RouterOutlet]"),
"Should extract dependency types, got:\n{}",
result.code
);
}
}
26 changes: 7 additions & 19 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,14 +1031,14 @@ fn ingest_element<'a>(
// Process local references
let local_refs = ingest_references_owned(allocator, element.references);

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

// Reorder groups to put @default LAST, matching Angular's compiled output.
// While Angular's ingestSwitchBlock iterates in source order, the downstream
// generateConditionalExpressions phase (conditionals.ts) splices @default out and
// uses it as the ternary fallback base. Because slot allocation and function naming
// happen after ingest, moving @default last here ensures our xref/slot/function
// ordering matches Angular's final output.
let mut groups_vec: std::vec::Vec<_> = switch_block.groups.into_iter().collect();
let default_idx = groups_vec.iter().position(|group| {
!group.cases.is_empty() && group.cases.iter().all(|c| c.expression.is_none())
});
if let Some(idx) = default_idx {
let default_group = groups_vec.remove(idx);
groups_vec.push(default_group);
}

// Iterate groups in source order, matching Angular TS's ingestSwitchBlock.
// The downstream generate_conditional_expressions phase handles @default at
// any position by splicing it out as the ternary fallback base.
let mut first_xref: Option<XrefId> = None;
let mut conditions: Vec<'a, ConditionalCaseExpr<'a>> = Vec::new_in(allocator);
let mut create_ops: std::vec::Vec<CreateOp<'a>> = std::vec::Vec::new();

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ fn specialize_in_view<'a>(
});
cursor.replace_current(new_op);
}
} else if name.as_str() == "field" {
// Check for special "field" property (control binding)
} else if name.as_str() == "formField" {
// Check for special "formField" property (control binding)
if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() {
let expression = std::mem::replace(
&mut binding.expression,
Expand Down
95 changes: 78 additions & 17 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3054,17 +3054,17 @@ fn test_svg_in_switch_case_with_whitespace() {

#[test]
fn test_control_binding_attribute_extraction() {
// Test that [field] (control binding) is extracted into the consts array.
// Test that [formField] (control binding) is extracted into the consts array.
// Before the fix, UpdateOp::Control was not handled in attribute extraction,
// causing the control binding name ("field") to be missing from the element's
// causing the control binding name ("formField") to be missing from the element's
// extracted attributes. This resulted in duplicate/shifted const entries.
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';

@Component({
selector: 'test-comp',
template: '<cu-comp [field]="myField" [open]="isOpen"></cu-comp>',
template: '<cu-comp [formField]="myField" [open]="isOpen"></cu-comp>',
standalone: true,
})
export class TestComponent {
Expand All @@ -3084,24 +3084,26 @@ export class TestComponent {
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
eprintln!("OUTPUT:\n{}", result.code);

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

// Both "field" and "open" should appear in the same consts entry (same element).
// Both "formField" and "open" should appear in the same consts entry (same element).
// The property marker (3) should precede both names.
// Expected: [3, "field", "open"] (property marker followed by both binding names)
// Without the fix: [3, "open"] (missing "field")
let has_both_in_same_const =
result.code.lines().any(|line| line.contains(r#""field""#) && line.contains(r#""open""#));
// Expected: [3, "formField", "open"] (property marker followed by both binding names)
// Without the fix: [3, "open"] (missing "formField")
let has_both_in_same_const = result
.code
.lines()
.any(|line| line.contains(r#""formField""#) && line.contains(r#""open""#));
assert!(
has_both_in_same_const,
"Both 'field' and 'open' should appear in the same consts entry. Output:\n{}",
"Both 'formField' and 'open' should appear in the same consts entry. Output:\n{}",
result.code
);
}
Expand All @@ -3125,7 +3127,7 @@ fn test_pipe_slot_in_control_binding_exact_slot() {
// Element is at slot 0, pipe is at slot 1.
// The pipeBind1 call should reference slot 1, not slot 0.
let js = compile_template_to_js(
r#"<cu-comp [field]="myField$ | async"></cu-comp>"#,
r#"<cu-comp [formField]="myField$ | async"></cu-comp>"#,
"TestComponent",
);
eprintln!("OUTPUT:\n{js}");
Expand All @@ -3147,7 +3149,7 @@ fn test_pipe_slot_in_control_binding_exact_slot() {
#[test]
fn test_pipe_in_field_binding_with_safe_nav() {
let js = compile_template_to_js(
r#"<cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>"#,
r#"<cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>"#,
"TestComponent",
);
eprintln!("OUTPUT:\n{js}");
Expand All @@ -3163,7 +3165,7 @@ fn test_pipe_in_field_binding_with_safe_nav() {
#[test]
fn test_pipe_in_field_in_ngif() {
let js = compile_template_to_js(
r#"<div *ngIf="show"><cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp></div>"#,
r#"<div *ngIf="show"><cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp></div>"#,
"TestComponent",
);
eprintln!("OUTPUT:\n{js}");
Expand All @@ -3174,7 +3176,7 @@ fn test_pipe_in_field_in_ngif() {
#[test]
fn test_pipe_in_field_in_if_block() {
let js = compile_template_to_js(
r#"@if (show) {<cu-comp [field]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>}"#,
r#"@if (show) {<cu-comp [formField]="(settings$ | async)?.workload?.field" [title]="name | uppercase"></cu-comp>}"#,
"TestComponent",
);
eprintln!("OUTPUT:\n{js}");
Expand Down Expand Up @@ -5376,3 +5378,62 @@ fn test_if_block_no_expression_skips_main_branch() {
}
assert!(!errors.is_empty(), "Should report a parse error for @if without expression");
}

// ============================================================================
// Regression: @switch with @default first should preserve source order
// ============================================================================

#[test]
fn test_switch_default_first_preserves_source_order() {
// When @default appears first in source, Angular TS preserves source order:
// Case_0 = default (Other), Case_1 = case(1) (One), Case_2 = case(2) (Two)
// The conditional expression puts default's slot as the ternary fallback.
let js = compile_template_to_js(
r"@switch (value) { @default { <div>Other</div> } @case (1) { <div>One</div> } @case (2) { <div>Two</div> } }",
"TestComponent",
);

// Case_0 should be the default (Other), NOT reordered
assert!(js.contains("Case_0_Template"), "Expected Case_0_Template in output. Got:\n{}", js);
let case0_start = js.find("Case_0_Template").unwrap();
let case0_body = &js[case0_start..case0_start + 200];
assert!(
case0_body.contains("Other"),
"Case_0 should render 'Other' (default in source order). Got:\n{}",
js
);
Comment thread
cursor[bot] marked this conversation as resolved.

// Conditional ternary: default slot (0) should be the fallback base
// Expected: (tmp === 1) ? 1 : (tmp === 2) ? 2 : 0
assert!(js.contains("2: 0)"), "Ternary fallback should be slot 0 (default). Got:\n{}", js);
}

// ============================================================================
// Regression: [field] should be a regular property, not a control binding
// ============================================================================

#[test]
fn test_field_property_not_control_binding() {
// [field] is a regular property binding, NOT a form control binding.
// Only [formField] should trigger control binding behavior.
// Before fix: [field] emitted controlCreate()/control() instructions.
// After fix: [field] emits regular property() instruction.
let js = compile_template_to_js(r#"<cu-comp [field]="myField"></cu-comp>"#, "TestComponent");

// Should NOT have controlCreate
assert!(
!js.contains("controlCreate"),
"[field] should NOT produce controlCreate. Got:\n{}",
js
);

// Should NOT have control() call
assert!(!js.contains("ɵɵcontrol("), "[field] should NOT produce ɵɵcontrol(). Got:\n{}", js);

// Should have regular property binding
assert!(
js.contains(r#"ɵɵproperty("field""#),
"[field] should produce regular ɵɵproperty(\"field\", ...). Got:\n{}",
js
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function TestComponent_Case_0_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0," ");
i0.ɵɵelementStart(1,"div");
i0.ɵɵtext(2,"One");
i0.ɵɵtext(2,"Other");
i0.ɵɵelementEnd();
i0.ɵɵtext(3," ");
}
Expand All @@ -15,7 +15,7 @@ function TestComponent_Case_1_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0," ");
i0.ɵɵelementStart(1,"div");
i0.ɵɵtext(2,"Two");
i0.ɵɵtext(2,"One");
i0.ɵɵelementEnd();
i0.ɵɵtext(3," ");
}
Expand All @@ -24,7 +24,7 @@ function TestComponent_Case_2_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0," ");
i0.ɵɵelementStart(1,"div");
i0.ɵɵtext(2,"Other");
i0.ɵɵtext(2,"Two");
i0.ɵɵelementEnd();
i0.ɵɵtext(3," ");
}
Expand All @@ -34,6 +34,6 @@ function TestComponent_Template(rf,ctx) {
4,0)(2,TestComponent_Case_2_Template,4,0); }
if ((rf & 2)) {
let tmp_0_0;
i0.ɵɵconditional((((tmp_0_0 = ctx.value) === 1)? 0: ((tmp_0_0 === 2)? 1: 2)));
i0.ɵɵconditional((((tmp_0_0 = ctx.value) === 1)? 1: ((tmp_0_0 === 2)? 2: 0)));
}
}
14 changes: 7 additions & 7 deletions napi/angular-compiler/benchmarks/bitwarden/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
"benchmark:incremental": "oxnode benchmark.ts --incremental"
},
"dependencies": {
"@angular/animations": "20.3.15",
"@angular/animations": "20.3.17",
"@angular/cdk": "20.2.14",
"@angular/common": "20.3.15",
"@angular/compiler": "20.3.15",
"@angular/common": "20.3.17",
"@angular/compiler": "20.3.17",
"@angular/core": "20.3.17",
"@angular/forms": "20.3.15",
"@angular/platform-browser": "20.3.15",
"@angular/platform-browser-dynamic": "20.3.15",
"@angular/router": "20.3.15",
"@angular/forms": "20.3.17",
"@angular/platform-browser": "20.3.17",
"@angular/platform-browser-dynamic": "20.3.17",
"@angular/router": "20.3.17",
"core-js": "^3.47.0",
"rxjs": "~7.8.0",
"tslib": "^2.8.1",
Expand Down
1 change: 0 additions & 1 deletion napi/angular-compiler/e2e/app/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import '@angular/compiler'
import { bootstrapApplication } from '@angular/platform-browser'

import { App } from './app/app.component'
Expand Down
Loading
Loading