Skip to content

Commit 9797271

Browse files
Brooooooklynclaude
andauthored
fix: i18n ICU expression ordering with pipes (#5)
R3Icu used HashMap for vars and placeholders, which does not preserve insertion order. JavaScript objects (used in Angular's ngtsc) do preserve insertion order. When ICU cases share interpolation expressions, the HashMap's non-deterministic iteration caused pipe expressions to be emitted before plain expressions in the i18nExp chain. Changed R3Icu.vars and R3Icu.placeholders from HashMap to ordered Vec<(Atom, ...)> with deduplication helpers matching JS object semantics. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ad9a858 commit 9797271

File tree

3 files changed

+101
-15
lines changed

3 files changed

+101
-15
lines changed

crates/oxc_angular_compiler/src/ast/r3.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,10 +1361,10 @@ pub struct R3HostElement<'a> {
13611361
/// An ICU message for internationalization.
13621362
#[derive(Debug)]
13631363
pub struct R3Icu<'a> {
1364-
/// Variable expressions.
1365-
pub vars: HashMap<'a, Atom<'a>, R3BoundText<'a>>,
1366-
/// Placeholder expressions.
1367-
pub placeholders: HashMap<'a, Atom<'a>, R3IcuPlaceholder<'a>>,
1364+
/// Variable expressions (ordered: must preserve insertion order like JS objects).
1365+
pub vars: Vec<'a, (Atom<'a>, R3BoundText<'a>)>,
1366+
/// Placeholder expressions (ordered: must preserve insertion order like JS objects).
1367+
pub placeholders: Vec<'a, (Atom<'a>, R3IcuPlaceholder<'a>)>,
13681368
/// Source span.
13691369
pub source_span: Span,
13701370
/// i18n metadata.

crates/oxc_angular_compiler/src/transform/html_to_r3.rs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,34 @@ pub struct TransformOptions {
9999
pub collect_comment_nodes: bool,
100100
}
101101

102+
/// Inserts or updates a var entry in an ordered Vec, preserving first-insertion order.
103+
/// This matches JS object semantics where reassigning an existing key keeps its position.
104+
fn ordered_insert_var<'a>(
105+
vec: &mut Vec<'a, (Atom<'a>, R3BoundText<'a>)>,
106+
key: Atom<'a>,
107+
value: R3BoundText<'a>,
108+
) {
109+
if let Some(existing) = vec.iter_mut().find(|(k, _)| *k == key) {
110+
existing.1 = value;
111+
} else {
112+
vec.push((key, value));
113+
}
114+
}
115+
116+
/// Inserts or updates a placeholder entry in an ordered Vec, preserving first-insertion order.
117+
/// This matches JS object semantics where reassigning an existing key keeps its position.
118+
fn ordered_insert_placeholder<'a>(
119+
vec: &mut Vec<'a, (Atom<'a>, R3IcuPlaceholder<'a>)>,
120+
key: Atom<'a>,
121+
value: R3IcuPlaceholder<'a>,
122+
) {
123+
if let Some(existing) = vec.iter_mut().find(|(k, _)| *k == key) {
124+
existing.1 = value;
125+
} else {
126+
vec.push((key, value));
127+
}
128+
}
129+
102130
/// Transforms HTML AST to R3 AST.
103131
pub struct HtmlToR3Transform<'a> {
104132
allocator: &'a Allocator,
@@ -1250,7 +1278,7 @@ impl<'a> HtmlToR3Transform<'a> {
12501278
};
12511279

12521280
// Create variable for the switch value (using VAR_* placeholder name)
1253-
let mut vars = HashMap::new_in(self.allocator);
1281+
let mut vars = Vec::new_in(self.allocator);
12541282
let switch_value_str = expansion.switch_value.as_str();
12551283
let switch_value_span = expansion.switch_value_span;
12561284

@@ -1262,7 +1290,7 @@ impl<'a> HtmlToR3Transform<'a> {
12621290
// This matches Angular's visitExpansion behavior where nested ICUs are visited first,
12631291
// and their VAR_* placeholders are added before the outer ICU's VAR_*.
12641292
// Ported from Angular's i18n_parser.ts:137-159
1265-
let mut placeholders = HashMap::new_in(self.allocator);
1293+
let mut placeholders = Vec::new_in(self.allocator);
12661294
for case in expansion.cases.iter() {
12671295
self.extract_placeholders_from_nodes(&case.expansion, &mut placeholders, &mut vars);
12681296
}
@@ -1271,7 +1299,8 @@ impl<'a> HtmlToR3Transform<'a> {
12711299
// This ensures the correct order: nested ICU vars first, then outer ICU var.
12721300
// Use the unique VAR_* placeholder name as the key, matching Angular's behavior.
12731301
// The expression_placeholder was already generated above with getUniquePlaceholder.
1274-
vars.insert(
1302+
ordered_insert_var(
1303+
&mut vars,
12751304
expression_placeholder.clone(),
12761305
R3BoundText { value: parse_result.ast, source_span: switch_value_span, i18n: None },
12771306
);
@@ -1290,8 +1319,8 @@ impl<'a> HtmlToR3Transform<'a> {
12901319
fn extract_placeholders_from_nodes(
12911320
&mut self,
12921321
nodes: &[HtmlNode<'a>],
1293-
placeholders: &mut HashMap<'a, Atom<'a>, R3IcuPlaceholder<'a>>,
1294-
vars: &mut HashMap<'a, Atom<'a>, R3BoundText<'a>>,
1322+
placeholders: &mut Vec<'a, (Atom<'a>, R3IcuPlaceholder<'a>)>,
1323+
vars: &mut Vec<'a, (Atom<'a>, R3BoundText<'a>)>,
12951324
) {
12961325
for node in nodes {
12971326
match node {
@@ -1335,10 +1364,11 @@ impl<'a> HtmlToR3Transform<'a> {
13351364
// Use the unique VAR_* placeholder name as the key, not the raw switch value.
13361365
// This is critical: when multiple nested ICUs have the same switch value
13371366
// (e.g., same pipe expression), they MUST have separate entries in the vars
1338-
// HashMap. Angular uses unique placeholder names (VAR_SELECT, VAR_SELECT_1,
1367+
// collection. Angular uses unique placeholder names (VAR_SELECT, VAR_SELECT_1,
13391368
// VAR_SELECT_2) to ensure each nested ICU creates its own TextOp with its
13401369
// own pipe slot allocation.
1341-
vars.insert(
1370+
ordered_insert_var(
1371+
vars,
13421372
var_placeholder_name,
13431373
R3BoundText {
13441374
value: parse_result.ast,
@@ -1357,7 +1387,7 @@ impl<'a> HtmlToR3Transform<'a> {
13571387
&mut self,
13581388
text: &str,
13591389
base_span: Span,
1360-
placeholders: &mut HashMap<'a, Atom<'a>, R3IcuPlaceholder<'a>>,
1390+
placeholders: &mut Vec<'a, (Atom<'a>, R3IcuPlaceholder<'a>)>,
13611391
) {
13621392
// Use default Angular interpolation markers
13631393
let start_marker = "{{";
@@ -1395,7 +1425,11 @@ impl<'a> HtmlToR3Transform<'a> {
13951425
source_span: interp_span,
13961426
i18n: None,
13971427
};
1398-
placeholders.insert(placeholder_key, R3IcuPlaceholder::BoundText(bound_text));
1428+
ordered_insert_placeholder(
1429+
placeholders,
1430+
placeholder_key,
1431+
R3IcuPlaceholder::BoundText(bound_text),
1432+
);
13991433

14001434
pos = abs_end;
14011435
} else {

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use oxc_span::Atom;
1919
fn compile_template_to_js(template: &str, component_name: &str) -> String {
2020
let allocator = Allocator::default();
2121

22-
// Stage 1: Parse HTML
23-
let parser = HtmlParser::new(&allocator, template, "test.html");
22+
// Stage 1: Parse HTML (with expansion forms enabled for ICU/plural support)
23+
let parser = HtmlParser::with_expansion_forms(&allocator, template, "test.html");
2424
let html_result = parser.parse();
2525

2626
// Check for parse errors
@@ -3817,6 +3817,58 @@ export class TestComponent {
38173817
);
38183818
}
38193819

3820+
/// Tests that i18n expressions with pipes maintain the correct template order.
3821+
/// When a text node inside an i18n span contains both plain expressions and pipe expressions,
3822+
/// the expressions must be emitted in their original template order.
3823+
/// Ported from Angular compliance test: r3_view_compiler_i18n/multiple_pipes.ts
3824+
#[test]
3825+
fn test_i18n_expression_ordering_with_pipes() {
3826+
let js = compile_template_to_js(
3827+
r#"<span i18n>{{ a }} and {{ b }} and {{ c }} and {{ b | uppercase }}</span>"#,
3828+
"TestComponent",
3829+
);
3830+
3831+
// Debug: print full output
3832+
// The i18nExp calls should follow template order:
3833+
// ctx.a, ctx.b, ctx.c, pipeBind1(..., ctx.b)
3834+
assert!(
3835+
js.contains("i18nExp(ctx.a)(ctx.b)(ctx.c)"),
3836+
"Expressions should be in template order. Full output:\n{js}"
3837+
);
3838+
}
3839+
3840+
/// Tests i18n expression ordering with ICU plural containing both plain and pipe expressions.
3841+
/// The credits-tooltip pattern: an i18n block with text interpolation + ICU plural where
3842+
/// the "other" case has a pipe expression that must come AFTER the plain expression.
3843+
///
3844+
/// Expected expression order (matching Angular ngtsc):
3845+
/// i18nExp(ctx.name)(ctx.count)(ctx.amount)(pipeBind1(..., ctx.count))
3846+
///
3847+
/// Bug: OXC was emitting pipeBind1 before ctx.amount (swapping expressions 2 and 3).
3848+
#[test]
3849+
fn test_i18n_expression_ordering_icu_plural_with_pipe() {
3850+
let js = compile_template_to_js(
3851+
r#"<div i18n>{{ name }} {count, plural, =1 {({{ amount }} credits x 1 user)} other {({{ amount }} credits x {{ count | number }} users)}}</div>"#,
3852+
"TestComponent",
3853+
);
3854+
3855+
// Extract the update block to check i18nExp ordering
3856+
let update_start = js.find("if ((rf & 2))").expect("should have update block");
3857+
let update_block = &js[update_start..];
3858+
3859+
// The plain expression (ctx.amount) must come BEFORE the pipe expression (pipeBind1)
3860+
// in the i18nExp chain. This matches Angular ngtsc behavior.
3861+
let amount_pos = update_block.find("ctx.amount").expect("should have ctx.amount in i18nExp");
3862+
let pipe_pos = update_block.find("pipeBind1").expect("should have pipeBind1 in i18nExp");
3863+
3864+
assert!(
3865+
amount_pos < pipe_pos,
3866+
"ctx.amount (plain expression) must come before pipeBind1 (pipe expression) in i18nExp chain.\n\
3867+
amount_pos={amount_pos}, pipe_pos={pipe_pos}\n\
3868+
Update block:\n{update_block}"
3869+
);
3870+
}
3871+
38203872
#[test]
38213873
fn test_nested_if_listener_ctx_reference() {
38223874
// Test: nested @if where a listener in the inner @if accesses component properties.

0 commit comments

Comments
 (0)