Skip to content

Commit a584add

Browse files
Brooooooklynclaude
andauthored
fix: propagate i18n context into @defer blocks and sub-blocks (#12)
@defer, @Placeholder, @Loading, and @error blocks inside i18n contexts were missing i18nStart/i18nEnd wrapping in their generated templates. The HTML-to-R3 transform hardcoded `i18n: None` for all defer-related blocks instead of calling `create_block_placeholder()` like @if, @for, and @switch blocks do. The IR ingestion layer also needed to pass the i18n metadata through to the TemplateOp so propagate_i18n_blocks can wrap deferred views. Fixes unlock-view-confirm mismatch in ClickUp comparison (23 → 22). Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9d38e9c commit a584add

5 files changed

Lines changed: 232 additions & 15 deletions

File tree

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3132,6 +3132,7 @@ fn ingest_defer_view<'a>(
31323132
job: &mut ComponentCompilationJob<'a>,
31333133
parent_xref: XrefId,
31343134
suffix: &str,
3135+
i18n: Option<I18nMeta<'a>>,
31353136
children: Option<Vec<'a, R3Node<'a>>>,
31363137
source_span: Option<oxc_span::Span>,
31373138
) -> Option<XrefId> {
@@ -3151,6 +3152,11 @@ fn ingest_defer_view<'a>(
31513152
// We use the same pattern here so that defer_resolve_targets can find elements by view xref.
31523153
let fn_name_suffix = Some(Atom::from(job.allocator.alloc_str(&format!("Defer{suffix}"))));
31533154

3155+
// Convert i18n metadata to placeholder, matching Angular's ingestDeferView which passes
3156+
// i18nMeta through to createTemplateOp. This enables propagate_i18n_blocks to wrap the
3157+
// deferred template with i18nStart/i18nEnd when inside an i18n context.
3158+
let i18n_placeholder = convert_i18n_meta_to_placeholder(i18n);
3159+
31543160
let template_op = CreateOp::Template(TemplateOp {
31553161
base: CreateOpBase { source_span, ..Default::default() },
31563162
xref: secondary_view, // Use view xref as TemplateOp xref, matching Angular
@@ -3166,7 +3172,7 @@ fn ingest_defer_view<'a>(
31663172
attributes: None,
31673173
local_refs: Vec::new_in(job.allocator),
31683174
local_refs_index: None,
3169-
i18n_placeholder: None,
3175+
i18n_placeholder,
31703176
});
31713177

31723178
// Push the TemplateOp to the parent view's create ops
@@ -3185,7 +3191,7 @@ fn ingest_defer_block<'a>(
31853191
) {
31863192
let xref = job.allocate_xref_id();
31873193

3188-
// Extract timing values and source spans before consuming the blocks
3194+
// Extract timing values, source spans, and i18n metadata before consuming the blocks
31893195
let placeholder_minimum_time = defer_block.placeholder.as_ref().and_then(|p| p.minimum_time);
31903196
let loading_minimum_time = defer_block.loading.as_ref().and_then(|l| l.minimum_time);
31913197
let loading_after_time = defer_block.loading.as_ref().and_then(|l| l.after_time);
@@ -3199,33 +3205,44 @@ fn ingest_defer_block<'a>(
31993205
job,
32003206
view_xref,
32013207
"", // Empty suffix for main content - becomes "Defer"
3208+
defer_block.i18n,
32023209
Some(defer_block.children),
32033210
Some(defer_block.source_span),
32043211
);
32053212

3213+
// Destructure sub-blocks to extract both children and i18n before consuming
3214+
let (loading_children, loading_i18n) = match defer_block.loading {
3215+
Some(l) => (Some(l.children), l.i18n),
3216+
None => (None, None),
3217+
};
32063218
let loading_template_xref = ingest_defer_view(
32073219
job,
32083220
view_xref,
32093221
"Loading",
3210-
defer_block.loading.map(|l| l.children),
3222+
loading_i18n,
3223+
loading_children,
32113224
loading_source_span,
32123225
);
32133226

3227+
let (placeholder_children, placeholder_i18n) = match defer_block.placeholder {
3228+
Some(p) => (Some(p.children), p.i18n),
3229+
None => (None, None),
3230+
};
32143231
let placeholder_template_xref = ingest_defer_view(
32153232
job,
32163233
view_xref,
32173234
"Placeholder",
3218-
defer_block.placeholder.map(|p| p.children),
3235+
placeholder_i18n,
3236+
placeholder_children,
32193237
placeholder_source_span,
32203238
);
32213239

3222-
let error_template_xref = ingest_defer_view(
3223-
job,
3224-
view_xref,
3225-
"Error",
3226-
defer_block.error.map(|e| e.children),
3227-
error_source_span,
3228-
);
3240+
let (error_children, error_i18n) = match defer_block.error {
3241+
Some(e) => (Some(e.children), e.i18n),
3242+
None => (None, None),
3243+
};
3244+
let error_template_xref =
3245+
ingest_defer_view(job, view_xref, "Error", error_i18n, error_children, error_source_span);
32293246

32303247
// Set own_resolver_fn based on emit mode
32313248
// This matches Angular's ingestDeferBlock behavior (ingest.ts lines 663-672)

crates/oxc_angular_compiler/src/transform/html_to_r3.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2461,14 +2461,23 @@ impl<'a> HtmlToR3Transform<'a> {
24612461
connected.parameters.iter().map(|p| p.expression.as_str()).collect();
24622462
let minimum_time = parse_placeholder_parameters(&params);
24632463

2464+
// Create i18n placeholder if inside an i18n context
2465+
let i18n = self.create_block_placeholder(
2466+
"placeholder",
2467+
&[],
2468+
connected.span,
2469+
connected.start_span,
2470+
connected.end_span,
2471+
);
2472+
24642473
placeholder = Some(R3DeferredBlockPlaceholder {
24652474
children: connected_children,
24662475
minimum_time,
24672476
source_span: connected.span,
24682477
name_span: connected.name_span,
24692478
start_source_span: connected.start_span,
24702479
end_source_span: connected.end_span,
2471-
i18n: None,
2480+
i18n,
24722481
});
24732482
}
24742483
BlockType::Loading => {
@@ -2477,6 +2486,15 @@ impl<'a> HtmlToR3Transform<'a> {
24772486
connected.parameters.iter().map(|p| p.expression.as_str()).collect();
24782487
let (after_time, minimum_time) = parse_loading_parameters(&params);
24792488

2489+
// Create i18n placeholder if inside an i18n context
2490+
let i18n = self.create_block_placeholder(
2491+
"loading",
2492+
&[],
2493+
connected.span,
2494+
connected.start_span,
2495+
connected.end_span,
2496+
);
2497+
24802498
loading = Some(R3DeferredBlockLoading {
24812499
children: connected_children,
24822500
after_time,
@@ -2485,17 +2503,26 @@ impl<'a> HtmlToR3Transform<'a> {
24852503
name_span: connected.name_span,
24862504
start_source_span: connected.start_span,
24872505
end_source_span: connected.end_span,
2488-
i18n: None,
2506+
i18n,
24892507
});
24902508
}
24912509
BlockType::Error => {
2510+
// Create i18n placeholder if inside an i18n context
2511+
let i18n = self.create_block_placeholder(
2512+
"error",
2513+
&[],
2514+
connected.span,
2515+
connected.start_span,
2516+
connected.end_span,
2517+
);
2518+
24922519
error = Some(R3DeferredBlockError {
24932520
children: connected_children,
24942521
source_span: connected.span,
24952522
name_span: connected.name_span,
24962523
start_source_span: connected.start_span,
24972524
end_source_span: connected.end_span,
2498-
i18n: None,
2525+
i18n,
24992526
});
25002527
}
25012528
_ => {}
@@ -2512,6 +2539,15 @@ impl<'a> HtmlToR3Transform<'a> {
25122539
block.span
25132540
};
25142541

2542+
// Create i18n placeholder for @defer block if inside i18n context
2543+
let i18n = self.create_block_placeholder(
2544+
"defer",
2545+
&[],
2546+
source_span,
2547+
block.start_span,
2548+
end_source_span,
2549+
);
2550+
25152551
let defer_block = R3DeferredBlock {
25162552
children,
25172553
triggers: trigger_result.triggers,
@@ -2525,7 +2561,7 @@ impl<'a> HtmlToR3Transform<'a> {
25252561
name_span: block.name_span,
25262562
start_source_span: block.start_span,
25272563
end_source_span,
2528-
i18n: None,
2564+
i18n,
25292565
};
25302566
Some(R3Node::DeferredBlock(Box::new_in(defer_block, self.allocator)))
25312567
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,87 @@ fn test_defer_block() {
471471
insta::assert_snapshot!("defer_block", js);
472472
}
473473

474+
/// Tests that @defer blocks inside i18n contexts get wrapped with i18nStart/i18nEnd.
475+
/// Angular propagates i18n context into defer view templates so that the deferred
476+
/// content is part of the i18n message. Each defer sub-block (main, loading,
477+
/// placeholder, error) gets its own sub-template index.
478+
///
479+
/// Ported from Angular compliance test:
480+
/// `r3_view_compiler_i18n/blocks/defer.ts`
481+
#[test]
482+
fn test_defer_inside_i18n() {
483+
let js = compile_template_to_js(
484+
r#"<div i18n>
485+
Content:
486+
@defer (when isLoaded) {
487+
before<span>middle</span>after
488+
} @placeholder {
489+
before<div>placeholder</div>after
490+
} @loading {
491+
before<button>loading</button>after
492+
} @error {
493+
before<h1>error</h1>after
494+
}
495+
</div>"#,
496+
"MyApp",
497+
);
498+
499+
// Each deferred template function should be wrapped with i18nStart/i18nEnd
500+
// with increasing sub-template indices (1, 2, 3, 4)
501+
assert!(
502+
js.contains("i18nStart(0,0,1)"),
503+
"Main defer template should have i18nStart with sub-template index 1. Output:\n{js}"
504+
);
505+
assert!(
506+
js.contains("i18nStart(0,0,2)"),
507+
"Loading defer template should have i18nStart with sub-template index 2. Output:\n{js}"
508+
);
509+
assert!(
510+
js.contains("i18nStart(0,0,3)"),
511+
"Placeholder defer template should have i18nStart with sub-template index 3. Output:\n{js}"
512+
);
513+
assert!(
514+
js.contains("i18nStart(0,0,4)"),
515+
"Error defer template should have i18nStart with sub-template index 4. Output:\n{js}"
516+
);
517+
518+
// The deferred templates should have 2 decls (i18nStart + element), not 1
519+
// domTemplate(N, fn, 2, 0) - 2 declarations for each deferred view
520+
assert!(
521+
js.contains("MyApp_Defer_2_Template,2,0)"),
522+
"Main defer domTemplate should have 2 decls. Output:\n{js}"
523+
);
524+
525+
insta::assert_snapshot!("defer_inside_i18n", js);
526+
}
527+
528+
/// When @defer is nested inside a structural directive (*ngIf template) that's inside
529+
/// an i18n context, the i18n wrapping must propagate through the template boundary
530+
/// to the defer view. This matches the unlock-view-confirm ClickUp pattern.
531+
#[test]
532+
fn test_defer_inside_structural_directive_in_i18n() {
533+
let js = compile_template_to_js(
534+
r#"<div i18n>
535+
text
536+
<span *ngIf="show">
537+
@defer (on idle) {
538+
<span>deferred</span>
539+
}
540+
</span>
541+
</div>"#,
542+
"MyApp",
543+
);
544+
545+
// The defer template should have i18nStart wrapping since it's
546+
// transitively inside an i18n context (through the *ngIf template)
547+
assert!(
548+
js.contains("i18nStart(0,"),
549+
"Defer template inside structural directive in i18n should have i18nStart. Output:\n{js}"
550+
);
551+
552+
insta::assert_snapshot!("defer_inside_structural_directive_in_i18n", js);
553+
}
554+
474555
#[test]
475556
fn test_defer_with_loading() {
476557
let js = compile_template_to_js(
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function MyApp_Defer_2_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵi18nStart(0,0,1);
8+
i0.ɵɵelement(1,"span");
9+
i0.ɵɵi18nEnd();
10+
}
11+
}
12+
function MyApp_DeferLoading_3_Template(rf,ctx) {
13+
if ((rf & 1)) {
14+
i0.ɵɵi18nStart(0,0,2);
15+
i0.ɵɵelement(1,"button");
16+
i0.ɵɵi18nEnd();
17+
}
18+
}
19+
function MyApp_DeferPlaceholder_4_Template(rf,ctx) {
20+
if ((rf & 1)) {
21+
i0.ɵɵi18nStart(0,0,3);
22+
i0.ɵɵelement(1,"div");
23+
i0.ɵɵi18nEnd();
24+
}
25+
}
26+
function MyApp_DeferError_5_Template(rf,ctx) {
27+
if ((rf & 1)) {
28+
i0.ɵɵi18nStart(0,0,4);
29+
i0.ɵɵelement(1,"h1");
30+
i0.ɵɵi18nEnd();
31+
}
32+
}
33+
function MyApp_Template(rf,ctx) {
34+
if ((rf & 1)) {
35+
i0.ɵɵelementStart(0,"div");
36+
i0.ɵɵi18nStart(1,0);
37+
i0.ɵɵdomTemplate(2,MyApp_Defer_2_Template,2,0)(3,MyApp_DeferLoading_3_Template,2,
38+
0)(4,MyApp_DeferPlaceholder_4_Template,2,0)(5,MyApp_DeferError_5_Template,2,
39+
0);
40+
i0.ɵɵdefer(6,2,null,3,4,5);
41+
i0.ɵɵi18nEnd();
42+
i0.ɵɵelementEnd();
43+
}
44+
if ((rf & 2)) {
45+
i0.ɵɵadvance(6);
46+
i0.ɵɵdeferWhen(ctx.isLoaded);
47+
}
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function MyApp_span_2_Defer_2_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵi18nStart(0,0,2);
8+
i0.ɵɵelement(1,"span");
9+
i0.ɵɵi18nEnd();
10+
}
11+
}
12+
function MyApp_span_2_Template(rf,ctx) {
13+
if ((rf & 1)) {
14+
i0.ɵɵi18nStart(0,0,1);
15+
i0.ɵɵelementStart(1,"span");
16+
i0.ɵɵdomTemplate(2,MyApp_span_2_Defer_2_Template,2,0);
17+
i0.ɵɵdefer(3,2);
18+
i0.ɵɵdeferOnIdle();
19+
i0.ɵɵelementEnd();
20+
i0.ɵɵi18nEnd();
21+
}
22+
}
23+
function MyApp_Template(rf,ctx) {
24+
if ((rf & 1)) {
25+
i0.ɵɵelementStart(0,"div");
26+
i0.ɵɵi18nStart(1,0);
27+
i0.ɵɵtemplate(2,MyApp_span_2_Template,5,0,"span",1);
28+
i0.ɵɵi18nEnd();
29+
i0.ɵɵelementEnd();
30+
}
31+
if ((rf & 2)) {
32+
i0.ɵɵadvance(2);
33+
i0.ɵɵproperty("ngIf",ctx.show);
34+
}
35+
}

0 commit comments

Comments
 (0)