Skip to content

Commit 5fce15e

Browse files
Brooooooklynclaude
andauthored
fix: preserve i18n metadata on structural directive attribute hoisting and split namespace attrs (#13)
When an element has both a structural directive (*ngIf) and i18n-translated attributes, the attribute hoisting in filter_animation_attributes/inputs was discarding i18n metadata (setting i18n: None). Additionally, ingest_template used the non-i18n variant for hoisted attributes, and namespace attributes (e.g. xmlns:xlink) were not split into namespace/name pairs. Together these caused incorrect const deduplication and index shifts. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a584add commit 5fce15e

3 files changed

Lines changed: 174 additions & 99 deletions

File tree

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 19 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,94 +1244,6 @@ fn ingest_element<'a>(
12441244
}
12451245
}
12461246

1247-
/// Ingests static attributes from an element into BindingOp operations.
1248-
///
1249-
/// Static attributes (e.g., `<div class="foo" ngNonBindable>`) are ingested as
1250-
/// BindingOp with `is_text_attribute: true` into the UPDATE list, just like
1251-
/// Angular's TypeScript implementation. This allows binding_specialization
1252-
/// to detect special attributes like `ngNonBindable` and handle them appropriately.
1253-
///
1254-
/// Ported from Angular's ingestElementBindings in template/pipeline/src/ingest.ts
1255-
///
1256-
/// `is_structural_template_attribute` - If true, use BindingKind::Template for the extracted
1257-
/// attributes. This is needed for structural directive attributes like `*cdkPortal` where
1258-
/// the attribute (cdkPortal) should be extracted with the Template marker, not as a regular
1259-
/// Attribute.
1260-
fn ingest_static_attributes<'a>(
1261-
job: &mut ComponentCompilationJob<'a>,
1262-
view_xref: XrefId,
1263-
element_xref: XrefId,
1264-
attributes: std::vec::Vec<(Atom<'a>, Atom<'a>)>,
1265-
is_structural_template_attribute: bool,
1266-
) {
1267-
use crate::output::ast::{LiteralExpr, LiteralValue, OutputExpression};
1268-
1269-
let allocator = job.allocator;
1270-
1271-
for (name, value) in attributes {
1272-
// ngNonBindable and animate.* require special handling: they must be added to the
1273-
// update list as BindingOp so binding_specialization can detect and process them.
1274-
// - ngNonBindable: marks element as non-bindable
1275-
// - animate.*: converts to AnimationBindingOp for animation instructions
1276-
if name.as_str() == "ngNonBindable" || name.as_str().starts_with("animate.") {
1277-
let literal_expr = OutputExpression::Literal(Box::new_in(
1278-
LiteralExpr { value: LiteralValue::String(value), source_span: None },
1279-
allocator,
1280-
));
1281-
let value_expr = IrExpression::OutputExpr(Box::new_in(literal_expr, allocator));
1282-
1283-
let binding = BindingOp {
1284-
base: UpdateOpBase::default(),
1285-
target: element_xref,
1286-
kind: BindingKind::Attribute,
1287-
name,
1288-
expression: Box::new_in(value_expr, allocator),
1289-
unit: None,
1290-
security_context: SecurityContext::None,
1291-
i18n_message: None,
1292-
is_text_attribute: true,
1293-
};
1294-
1295-
if let Some(view) = job.view_mut(view_xref) {
1296-
view.update.push(UpdateOp::Binding(binding));
1297-
}
1298-
continue;
1299-
}
1300-
1301-
// All other static attributes go to the create list as ExtractedAttributeOp
1302-
let literal_expr = OutputExpression::Literal(Box::new_in(
1303-
LiteralExpr { value: LiteralValue::String(value), source_span: None },
1304-
allocator,
1305-
));
1306-
let value_expr = IrExpression::OutputExpr(Box::new_in(literal_expr, allocator));
1307-
1308-
// Use Template kind for structural template attributes, Attribute otherwise
1309-
let binding_kind = if is_structural_template_attribute {
1310-
BindingKind::Template
1311-
} else {
1312-
BindingKind::Attribute
1313-
};
1314-
1315-
let extracted = ExtractedAttributeOp {
1316-
base: CreateOpBase::default(),
1317-
target: element_xref,
1318-
binding_kind,
1319-
namespace: None,
1320-
name,
1321-
value: Some(Box::new_in(value_expr, allocator)),
1322-
security_context: SecurityContext::None,
1323-
truthy_expression: false,
1324-
i18n_context: None,
1325-
i18n_message: None,
1326-
trusted_value_fn: None,
1327-
};
1328-
1329-
if let Some(view) = job.view_mut(view_xref) {
1330-
view.create.push(CreateOp::ExtractedAttribute(extracted));
1331-
}
1332-
}
1333-
}
1334-
13351247
/// Ingests static attributes from R3TextAttribute, preserving i18n metadata.
13361248
///
13371249
/// This version takes R3TextAttribute directly so it can access the i18n field.
@@ -1448,12 +1360,17 @@ fn ingest_static_attributes_with_i18n<'a>(
14481360
BindingKind::Attribute
14491361
};
14501362

1363+
// Split namespace from attribute name (e.g., `:xmlns:xlink` → namespace="xmlns", name="xlink")
1364+
let (ns, local_name) = split_ns_name(name.as_str());
1365+
let namespace = ns.map(|n| Atom::from(n));
1366+
let local_name = Atom::from(local_name);
1367+
14511368
let extracted = ExtractedAttributeOp {
14521369
base: CreateOpBase::default(),
14531370
target: element_xref,
14541371
binding_kind,
1455-
namespace: None,
1456-
name,
1372+
namespace,
1373+
name: local_name,
14571374
value: Some(Box::new_in(value_expr, allocator)),
14581375
security_context: SecurityContext::None,
14591376
truthy_expression: false,
@@ -1523,12 +1440,17 @@ fn ingest_single_static_attribute<'a>(
15231440
}
15241441
} else {
15251442
// For regular (non-structural) attributes, create ExtractedAttributeOp directly
1443+
// Split namespace from attribute name (e.g., `:xmlns:xlink` → namespace="xmlns", name="xlink")
1444+
let (ns, local_name) = split_ns_name(name.as_str());
1445+
let namespace = ns.map(|n| Atom::from(n));
1446+
let local_name = Atom::from(local_name);
1447+
15261448
let extracted = ExtractedAttributeOp {
15271449
base: CreateOpBase::default(),
15281450
target: element_xref,
15291451
binding_kind: BindingKind::Attribute,
1530-
namespace: None,
1531-
name,
1452+
namespace,
1453+
name: local_name,
15321454
value: Some(Box::new_in(value_expr, allocator)),
15331455
security_context: SecurityContext::None,
15341456
truthy_expression: false,
@@ -2088,12 +2010,12 @@ fn ingest_template<'a>(
20882010
}
20892011

20902012
// Process hoisted static attributes from the wrapped element.
2091-
// Ported from Angular's `ingestTemplateBindings` - attributes processing (lines 1471-1489).
2092-
let static_attrs: std::vec::Vec<_> =
2093-
attributes.into_iter().map(|attr| (attr.name, attr.value)).collect();
2094-
if !static_attrs.is_empty() {
2013+
// Ported from Angular's `ingestTemplateBindings` - attributes processing (lines 1471-1501).
2014+
// IMPORTANT: Use ingest_static_attributes_with_i18n to preserve i18n metadata (attr.i18n).
2015+
// Angular's TS passes asMessage(attr.i18n) to createTemplateBinding (ingest.ts line 1497).
2016+
if !attributes.is_empty() {
20952017
// Hoisted attributes from wrapped element are regular attributes, not Template
2096-
ingest_static_attributes(job, view_xref, xref, static_attrs, false);
2018+
ingest_static_attributes_with_i18n(job, view_xref, xref, &attributes, false);
20972019
}
20982020

20992021
// Process hoisted inputs from the wrapped element (e.g., [class]="..." on <div *ngIf>).

crates/oxc_angular_compiler/src/transform/html_to_r3.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3710,7 +3710,7 @@ impl<'a> HtmlToR3Transform<'a> {
37103710
source_span: attr.source_span,
37113711
key_span: attr.key_span,
37123712
value_span: attr.value_span,
3713-
i18n: None,
3713+
i18n: attr.i18n.as_ref().map(|meta| meta.clone_in(self.allocator)),
37143714
});
37153715
}
37163716
}
@@ -3735,7 +3735,7 @@ impl<'a> HtmlToR3Transform<'a> {
37353735
source_span: input.source_span,
37363736
key_span: input.key_span,
37373737
value_span: input.value_span,
3738-
i18n: None,
3738+
i18n: input.i18n.as_ref().map(|meta| meta.clone_in(self.allocator)),
37393739
});
37403740
}
37413741
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4221,3 +4221,156 @@ export class TestComponent {
42214221
metadata_section
42224222
);
42234223
}
4224+
4225+
// ============================================================================
4226+
// Namespace Attribute Const Collection Tests
4227+
// ============================================================================
4228+
4229+
/// Test that SVG elements with namespace attributes (xmlns:xlink) produce correct consts.
4230+
///
4231+
/// When a static attribute like `xmlns:xlink="..."` is ingested, the name should be
4232+
/// split into namespace="xmlns" and name="xlink" so that the consts array serializes it
4233+
/// as [AttributeMarker.NamespaceUri, "xmlns", "xlink", "..."] instead of
4234+
/// [":xmlns:xlink", "..."].
4235+
///
4236+
/// Without the fix, namespace attributes create duplicate consts entries because
4237+
/// the unsplit `:xmlns:xlink` format doesn't match the properly-split format,
4238+
/// preventing deduplication and shifting all subsequent consts indices.
4239+
#[test]
4240+
fn test_svg_namespace_attribute_consts() {
4241+
let allocator = Allocator::default();
4242+
let source = r#"
4243+
import { Component } from '@angular/core';
4244+
4245+
@Component({
4246+
selector: 'app-icon',
4247+
standalone: true,
4248+
template: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-testid="icon"><use></use></svg>',
4249+
})
4250+
export class IconComponent {}
4251+
"#;
4252+
4253+
let result = transform_angular_file(
4254+
&allocator,
4255+
"icon.component.ts",
4256+
source,
4257+
&ComponentTransformOptions::default(),
4258+
None,
4259+
);
4260+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4261+
let code = &result.code;
4262+
eprintln!("OUTPUT:\n{code}");
4263+
4264+
// The consts array should NOT contain ":xmlns:xlink" as a raw string.
4265+
// Instead, namespace attributes should be serialized with the NamespaceUri marker (0).
4266+
assert!(
4267+
!code.contains(r#"":xmlns:xlink""#),
4268+
"Consts should NOT contain raw ':xmlns:xlink' string. Namespace should be split. Output:\n{code}"
4269+
);
4270+
4271+
// The consts array SHOULD contain the proper namespace marker format:
4272+
// 0 (NamespaceUri marker), "xmlns", "xlink"
4273+
assert!(
4274+
code.contains(r#"0,"xmlns","xlink""#),
4275+
"Consts should contain namespace marker format: 0,\"xmlns\",\"xlink\". Output:\n{code}"
4276+
);
4277+
}
4278+
4279+
/// Test that SVG with both namespace attributes and property bindings has correct consts indices.
4280+
///
4281+
/// This reproduces the real-world icon.component pattern where:
4282+
/// - An @if conditional wraps the SVG (creating a template function)
4283+
/// - The SVG has namespace attrs (xmlns:xlink) AND a property binding ([name])
4284+
/// - Both the conditional template and the SVG element need consts entries
4285+
///
4286+
/// Without the fix, a duplicate consts entry is created for the SVG element,
4287+
/// causing the template to reference the wrong consts index.
4288+
#[test]
4289+
fn test_svg_namespace_attrs_with_conditional_and_binding() {
4290+
let allocator = Allocator::default();
4291+
let source = r#"
4292+
import { Component, Input } from '@angular/core';
4293+
4294+
@Component({
4295+
selector: 'app-icon',
4296+
standalone: true,
4297+
template: `@if (showIcon) {<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-testid="icon" class="svg"><use></use></svg>}`,
4298+
})
4299+
export class IconComponent {
4300+
showIcon = true;
4301+
}
4302+
"#;
4303+
4304+
let result = transform_angular_file(
4305+
&allocator,
4306+
"icon.component.ts",
4307+
source,
4308+
&ComponentTransformOptions::default(),
4309+
None,
4310+
);
4311+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4312+
let code = &result.code;
4313+
eprintln!("OUTPUT:\n{code}");
4314+
4315+
// Should NOT have duplicate consts entries. Count occurrences of "xmlns" in consts.
4316+
// With the bug, there would be two entries - one with proper namespace format,
4317+
// one with raw ":xmlns:xlink".
4318+
assert!(
4319+
!code.contains(r#"":xmlns:xlink""#),
4320+
"Consts should NOT contain raw ':xmlns:xlink' string. Output:\n{code}"
4321+
);
4322+
}
4323+
4324+
/// When an element has a structural directive (*ngIf) AND an i18n-translated attribute,
4325+
/// the hoisted static attributes must preserve the i18n info. Without this, the literal
4326+
/// text value is used in the consts array instead of the i18n variable reference, causing
4327+
/// incorrect deduplication when multiple similar elements exist.
4328+
///
4329+
/// Ported to match Angular TS behavior: ingestTemplateBindings passes attr.i18n to
4330+
/// createTemplateBinding for hoisted attributes (ingest.ts line 1497).
4331+
#[test]
4332+
fn test_i18n_attribute_on_structural_directive_element() {
4333+
let allocator = Allocator::default();
4334+
// Two buttons with *ngIf, both with i18n-cuTooltip but different custom IDs.
4335+
// Each should get its own i18n variable (i18n_0, i18n_1) in the consts array.
4336+
// Without the fix, both get literal "Clear date" and are deduplicated into one entry.
4337+
let source = r#"
4338+
import { Component } from '@angular/core';
4339+
4340+
@Component({
4341+
selector: 'app-test',
4342+
standalone: true,
4343+
template: `
4344+
<button *ngIf="showA" cuTooltip="Clear date" i18n-cuTooltip="@@clear-date-a" (click)="clearA()">Clear A</button>
4345+
<button *ngIf="showB" cuTooltip="Clear date" i18n-cuTooltip="@@clear-date-b" (click)="clearB()">Clear B</button>
4346+
`,
4347+
})
4348+
export class TestComponent {
4349+
showA = true;
4350+
showB = true;
4351+
clearA() {}
4352+
clearB() {}
4353+
}
4354+
"#;
4355+
4356+
let result = transform_angular_file(
4357+
&allocator,
4358+
"test.component.ts",
4359+
source,
4360+
&ComponentTransformOptions::default(),
4361+
None,
4362+
);
4363+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
4364+
let code = &result.code;
4365+
eprintln!("OUTPUT:\n{code}");
4366+
4367+
// Both buttons should use i18n variable references, not the literal "Clear date".
4368+
// The consts array should contain i18n_0 and i18n_1 (or similar), NOT literal "Clear date".
4369+
assert!(
4370+
!code.contains(r#""cuTooltip","Clear date""#),
4371+
"Consts should NOT contain literal 'Clear date' - should use i18n variable reference. Output:\n{code}"
4372+
);
4373+
// There should be two distinct i18n entries (i18n_0, i18n_1) for the two different @@ IDs
4374+
assert!(code.contains("i18n_0"), "Should have i18n_0 variable. Output:\n{code}");
4375+
assert!(code.contains("i18n_1"), "Should have i18n_1 variable. Output:\n{code}");
4376+
}

0 commit comments

Comments
 (0)