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
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,29 @@ fn process_view_attributes<'a>(
continue;
}

// Determine the extracted binding kind.
// Ported from Angular's attribute_extraction.ts lines 32-39:
// if (op.i18nMessage !== null && op.templateKind === null) {
// bindingKind = ir.BindingKind.I18n;
// } else if (op.isStructuralTemplateAttribute) {
// bindingKind = ir.BindingKind.Template;
// } else {
// bindingKind = ir.BindingKind.Property;
// }
let binding_kind = if prop_op.i18n_message.is_some()
&& prop_op.binding_kind != BindingKind::Template
{
BindingKind::I18n
} else {
prop_op.binding_kind
};

// Properties also generate extracted attributes for directive matching
// Note: Property ops are NOT removed - they still need runtime updates
// Use the actual binding_kind from the op (may be Template for structural directives)
let extracted = ExtractedAttributeOp {
base: CreateOpBase::default(),
target: prop_op.target,
binding_kind: prop_op.binding_kind,
binding_kind,
namespace: None,
name: prop_op.name.clone(),
value: None, // Property bindings don't copy the expression
Expand Down
21 changes: 12 additions & 9 deletions crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2605,13 +2605,16 @@ impl<'a> HtmlToR3Transform<'a> {
None,
));
} else {
inputs.push(self.create_bound_attribute(
let i18n = i18n_attrs_meta.remove(rest);
let mut bound_attr = self.create_bound_attribute(
element_name,
rest,
attr,
BindingType::Property,
None,
));
);
bound_attr.i18n = i18n;
inputs.push(bound_attr);
}
}
BindingPrefix::Let => {
Expand Down Expand Up @@ -2717,13 +2720,13 @@ impl<'a> HtmlToR3Transform<'a> {
} else {
(BindingType::Property, prop_name, None)
};
inputs.push(self.create_bound_attribute(
element_name,
final_name,
attr,
binding_type,
unit,
));
// Look up i18n metadata for this property binding (e.g., i18n-heading for [heading])
// Ported from Angular's categorizePropertyAttributes in r3_template_transform.ts
let i18n = i18n_attrs_meta.remove(final_name);
let mut bound_attr =
self.create_bound_attribute(element_name, final_name, attr, binding_type, unit);
bound_attr.i18n = i18n;
inputs.push(bound_attr);
continue;
}

Expand Down
78 changes: 78 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3572,3 +3572,81 @@ fn test_let_declaration_with_multiple_context_refs_variable_naming() {
"Context variable used by both @let and conditional should be named properly, not _unnamed_. Output:\n{js}"
);
}

// ============================================================================
// Const reference index: i18n property binding extraction
// ============================================================================

/// Tests that property bindings with i18n markers are extracted as BindingKind::I18n
/// in the consts array. Angular's attribute_extraction.ts checks `op.i18nMessage !== null`
/// on Property ops and converts them to BindingKind.I18n. Without this, the const entry
/// would be `[3, "heading"]` (Bindings marker) instead of `[6, "heading"]` (I18n marker),
/// causing const index mismatches.
#[test]
fn test_i18n_property_binding_extracted_as_i18n_kind() {
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';

@Component({
selector: 'test-comp',
template: '<my-comp [heading]="title" i18n-heading="@@my-heading">content</my-comp>',
standalone: true,
})
export class TestComponent {
title = 'hello';
}
"#;

let result = transform_angular_file(
&allocator,
"test.component.ts",
source,
&ComponentTransformOptions::default(),
None,
);

// The consts array should contain [6,"heading"] (AttributeMarker.I18n = 6)
// not [3,"heading"] (AttributeMarker.Bindings = 3)
assert!(
result.code.contains(r#"6,"heading""#),
"Property binding with i18n marker should produce I18n AttributeMarker (6), not Bindings (3). Output:\n{}",
result.code
);
}

/// Tests that interpolated attributes with i18n markers (e.g., heading="{{ name }}" i18n-heading)
/// are extracted as BindingKind::I18n in the consts array.
/// This matches the real-world pattern in ClickUp's old-join-team component.
#[test]
fn test_i18n_interpolated_attribute_extracted_as_i18n_kind() {
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';

@Component({
selector: 'test-comp',
template: '<my-comp heading="Join the {{ name }} Workspace" i18n-heading="@@join-workspace">content</my-comp>',
standalone: true,
})
export class TestComponent {
name = 'hello';
}
"#;

let result = transform_angular_file(
&allocator,
"test.component.ts",
source,
&ComponentTransformOptions::default(),
None,
);

// The consts array should contain [6,"heading"] (AttributeMarker.I18n = 6)
// not [3,"heading"] (AttributeMarker.Bindings = 3)
assert!(
result.code.contains(r#"6,"heading""#),
"Interpolated attribute with i18n marker should produce I18n AttributeMarker (6), not Bindings (3). Output:\n{}",
result.code
);
}
Loading