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
+ );
+}