diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs b/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs index 4d3294eb0..4acab4527 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs @@ -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 diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index fbe0c3a63..220a89c47 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -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 => { @@ -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; } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index af6bee0a9..88d35a968 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -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: 'content', + 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: 'content', + 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 + ); +}