diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 309e26f86..ae9428f1a 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -3132,6 +3132,7 @@ fn ingest_defer_view<'a>( job: &mut ComponentCompilationJob<'a>, parent_xref: XrefId, suffix: &str, + i18n: Option>, children: Option>>, source_span: Option, ) -> Option { @@ -3151,6 +3152,11 @@ fn ingest_defer_view<'a>( // We use the same pattern here so that defer_resolve_targets can find elements by view xref. let fn_name_suffix = Some(Atom::from(job.allocator.alloc_str(&format!("Defer{suffix}")))); + // Convert i18n metadata to placeholder, matching Angular's ingestDeferView which passes + // i18nMeta through to createTemplateOp. This enables propagate_i18n_blocks to wrap the + // deferred template with i18nStart/i18nEnd when inside an i18n context. + let i18n_placeholder = convert_i18n_meta_to_placeholder(i18n); + let template_op = CreateOp::Template(TemplateOp { base: CreateOpBase { source_span, ..Default::default() }, xref: secondary_view, // Use view xref as TemplateOp xref, matching Angular @@ -3166,7 +3172,7 @@ fn ingest_defer_view<'a>( attributes: None, local_refs: Vec::new_in(job.allocator), local_refs_index: None, - i18n_placeholder: None, + i18n_placeholder, }); // Push the TemplateOp to the parent view's create ops @@ -3185,7 +3191,7 @@ fn ingest_defer_block<'a>( ) { let xref = job.allocate_xref_id(); - // Extract timing values and source spans before consuming the blocks + // Extract timing values, source spans, and i18n metadata before consuming the blocks let placeholder_minimum_time = defer_block.placeholder.as_ref().and_then(|p| p.minimum_time); let loading_minimum_time = defer_block.loading.as_ref().and_then(|l| l.minimum_time); let loading_after_time = defer_block.loading.as_ref().and_then(|l| l.after_time); @@ -3199,33 +3205,44 @@ fn ingest_defer_block<'a>( job, view_xref, "", // Empty suffix for main content - becomes "Defer" + defer_block.i18n, Some(defer_block.children), Some(defer_block.source_span), ); + // Destructure sub-blocks to extract both children and i18n before consuming + let (loading_children, loading_i18n) = match defer_block.loading { + Some(l) => (Some(l.children), l.i18n), + None => (None, None), + }; let loading_template_xref = ingest_defer_view( job, view_xref, "Loading", - defer_block.loading.map(|l| l.children), + loading_i18n, + loading_children, loading_source_span, ); + let (placeholder_children, placeholder_i18n) = match defer_block.placeholder { + Some(p) => (Some(p.children), p.i18n), + None => (None, None), + }; let placeholder_template_xref = ingest_defer_view( job, view_xref, "Placeholder", - defer_block.placeholder.map(|p| p.children), + placeholder_i18n, + placeholder_children, placeholder_source_span, ); - let error_template_xref = ingest_defer_view( - job, - view_xref, - "Error", - defer_block.error.map(|e| e.children), - error_source_span, - ); + let (error_children, error_i18n) = match defer_block.error { + Some(e) => (Some(e.children), e.i18n), + None => (None, None), + }; + let error_template_xref = + ingest_defer_view(job, view_xref, "Error", error_i18n, error_children, error_source_span); // Set own_resolver_fn based on emit mode // This matches Angular's ingestDeferBlock behavior (ingest.ts lines 663-672) 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 8a79276b2..532f09672 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -2461,6 +2461,15 @@ impl<'a> HtmlToR3Transform<'a> { connected.parameters.iter().map(|p| p.expression.as_str()).collect(); let minimum_time = parse_placeholder_parameters(¶ms); + // Create i18n placeholder if inside an i18n context + let i18n = self.create_block_placeholder( + "placeholder", + &[], + connected.span, + connected.start_span, + connected.end_span, + ); + placeholder = Some(R3DeferredBlockPlaceholder { children: connected_children, minimum_time, @@ -2468,7 +2477,7 @@ impl<'a> HtmlToR3Transform<'a> { name_span: connected.name_span, start_source_span: connected.start_span, end_source_span: connected.end_span, - i18n: None, + i18n, }); } BlockType::Loading => { @@ -2477,6 +2486,15 @@ impl<'a> HtmlToR3Transform<'a> { connected.parameters.iter().map(|p| p.expression.as_str()).collect(); let (after_time, minimum_time) = parse_loading_parameters(¶ms); + // Create i18n placeholder if inside an i18n context + let i18n = self.create_block_placeholder( + "loading", + &[], + connected.span, + connected.start_span, + connected.end_span, + ); + loading = Some(R3DeferredBlockLoading { children: connected_children, after_time, @@ -2485,17 +2503,26 @@ impl<'a> HtmlToR3Transform<'a> { name_span: connected.name_span, start_source_span: connected.start_span, end_source_span: connected.end_span, - i18n: None, + i18n, }); } BlockType::Error => { + // Create i18n placeholder if inside an i18n context + let i18n = self.create_block_placeholder( + "error", + &[], + connected.span, + connected.start_span, + connected.end_span, + ); + error = Some(R3DeferredBlockError { children: connected_children, source_span: connected.span, name_span: connected.name_span, start_source_span: connected.start_span, end_source_span: connected.end_span, - i18n: None, + i18n, }); } _ => {} @@ -2512,6 +2539,15 @@ impl<'a> HtmlToR3Transform<'a> { block.span }; + // Create i18n placeholder for @defer block if inside i18n context + let i18n = self.create_block_placeholder( + "defer", + &[], + source_span, + block.start_span, + end_source_span, + ); + let defer_block = R3DeferredBlock { children, triggers: trigger_result.triggers, @@ -2525,7 +2561,7 @@ impl<'a> HtmlToR3Transform<'a> { name_span: block.name_span, start_source_span: block.start_span, end_source_span, - i18n: None, + i18n, }; Some(R3Node::DeferredBlock(Box::new_in(defer_block, self.allocator))) } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 29ee4883b..61156d9d0 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -471,6 +471,87 @@ fn test_defer_block() { insta::assert_snapshot!("defer_block", js); } +/// Tests that @defer blocks inside i18n contexts get wrapped with i18nStart/i18nEnd. +/// Angular propagates i18n context into defer view templates so that the deferred +/// content is part of the i18n message. Each defer sub-block (main, loading, +/// placeholder, error) gets its own sub-template index. +/// +/// Ported from Angular compliance test: +/// `r3_view_compiler_i18n/blocks/defer.ts` +#[test] +fn test_defer_inside_i18n() { + let js = compile_template_to_js( + r#"
+ Content: + @defer (when isLoaded) { + beforemiddleafter + } @placeholder { + before
placeholder
after + } @loading { + beforeafter + } @error { + before

error

after + } +
"#, + "MyApp", + ); + + // Each deferred template function should be wrapped with i18nStart/i18nEnd + // with increasing sub-template indices (1, 2, 3, 4) + assert!( + js.contains("i18nStart(0,0,1)"), + "Main defer template should have i18nStart with sub-template index 1. Output:\n{js}" + ); + assert!( + js.contains("i18nStart(0,0,2)"), + "Loading defer template should have i18nStart with sub-template index 2. Output:\n{js}" + ); + assert!( + js.contains("i18nStart(0,0,3)"), + "Placeholder defer template should have i18nStart with sub-template index 3. Output:\n{js}" + ); + assert!( + js.contains("i18nStart(0,0,4)"), + "Error defer template should have i18nStart with sub-template index 4. Output:\n{js}" + ); + + // The deferred templates should have 2 decls (i18nStart + element), not 1 + // domTemplate(N, fn, 2, 0) - 2 declarations for each deferred view + assert!( + js.contains("MyApp_Defer_2_Template,2,0)"), + "Main defer domTemplate should have 2 decls. Output:\n{js}" + ); + + insta::assert_snapshot!("defer_inside_i18n", js); +} + +/// When @defer is nested inside a structural directive (*ngIf template) that's inside +/// an i18n context, the i18n wrapping must propagate through the template boundary +/// to the defer view. This matches the unlock-view-confirm ClickUp pattern. +#[test] +fn test_defer_inside_structural_directive_in_i18n() { + let js = compile_template_to_js( + r#"
+ text + + @defer (on idle) { + deferred + } + +
"#, + "MyApp", + ); + + // The defer template should have i18nStart wrapping since it's + // transitively inside an i18n context (through the *ngIf template) + assert!( + js.contains("i18nStart(0,"), + "Defer template inside structural directive in i18n should have i18nStart. Output:\n{js}" + ); + + insta::assert_snapshot!("defer_inside_structural_directive_in_i18n", js); +} + #[test] fn test_defer_with_loading() { let js = compile_template_to_js( diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_i18n.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_i18n.snap new file mode 100644 index 000000000..7b4cd200c --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_i18n.snap @@ -0,0 +1,48 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function MyApp_Defer_2_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,1); + i0.ɵɵelement(1,"span"); + i0.ɵɵi18nEnd(); + } +} +function MyApp_DeferLoading_3_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,2); + i0.ɵɵelement(1,"button"); + i0.ɵɵi18nEnd(); + } +} +function MyApp_DeferPlaceholder_4_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,3); + i0.ɵɵelement(1,"div"); + i0.ɵɵi18nEnd(); + } +} +function MyApp_DeferError_5_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,4); + i0.ɵɵelement(1,"h1"); + i0.ɵɵi18nEnd(); + } +} +function MyApp_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵelementStart(0,"div"); + i0.ɵɵi18nStart(1,0); + i0.ɵɵdomTemplate(2,MyApp_Defer_2_Template,2,0)(3,MyApp_DeferLoading_3_Template,2, + 0)(4,MyApp_DeferPlaceholder_4_Template,2,0)(5,MyApp_DeferError_5_Template,2, + 0); + i0.ɵɵdefer(6,2,null,3,4,5); + i0.ɵɵi18nEnd(); + i0.ɵɵelementEnd(); + } + if ((rf & 2)) { + i0.ɵɵadvance(6); + i0.ɵɵdeferWhen(ctx.isLoaded); + } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_structural_directive_in_i18n.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_structural_directive_in_i18n.snap new file mode 100644 index 000000000..971610325 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__defer_inside_structural_directive_in_i18n.snap @@ -0,0 +1,35 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function MyApp_span_2_Defer_2_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,2); + i0.ɵɵelement(1,"span"); + i0.ɵɵi18nEnd(); + } +} +function MyApp_span_2_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵi18nStart(0,0,1); + i0.ɵɵelementStart(1,"span"); + i0.ɵɵdomTemplate(2,MyApp_span_2_Defer_2_Template,2,0); + i0.ɵɵdefer(3,2); + i0.ɵɵdeferOnIdle(); + i0.ɵɵelementEnd(); + i0.ɵɵi18nEnd(); + } +} +function MyApp_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵelementStart(0,"div"); + i0.ɵɵi18nStart(1,0); + i0.ɵɵtemplate(2,MyApp_span_2_Template,5,0,"span",1); + i0.ɵɵi18nEnd(); + i0.ɵɵelementEnd(); + } + if ((rf & 2)) { + i0.ɵɵadvance(2); + i0.ɵɵproperty("ngIf",ctx.show); + } +}