Skip to content

Commit aabfc01

Browse files
Brooooooklynclaude
andauthored
fix: defer timer consts placed after i18n consts in consts array (#21)
Defer timer configs (e.g. [100, null] for @Loading minimum) were being added directly to the consts array in Phase 19 (defer_configs), placing them before i18n consts added in Phase 52. This caused all i18n const indices to be off by 1. Match Angular TS's approach: wrap timer configs in ConstCollectedExpr so they are resolved by Phase 53 (collectConstExpressions) which runs after Phase 52 (collectI18nConsts), ensuring correct ordering. Also fixes null vs 0 for missing timer values — Angular uses null for absent loadingAfterTime, not 0. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a23d741 commit aabfc01

File tree

6 files changed

+238
-42
lines changed

6 files changed

+238
-42
lines changed

crates/oxc_angular_compiler/src/ir/expression.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2147,6 +2147,20 @@ pub fn transform_expressions_in_create_op<'a, F>(
21472147
}
21482148
}
21492149
}
2150+
// Defer: transform loadingConfig and placeholderConfig expressions.
2151+
// Matches Angular TS expression.ts lines 1241-1251:
2152+
// case OpKind.Defer:
2153+
// if (op.loadingConfig !== null) { op.loadingConfig = transform(op.loadingConfig); }
2154+
// if (op.placeholderConfig !== null) { op.placeholderConfig = transform(op.placeholderConfig); }
2155+
// if (op.resolverFn !== null) { op.resolverFn = transform(op.resolverFn); }
2156+
CreateOp::Defer(op) => {
2157+
if let Some(ref mut config) = op.loading_config {
2158+
transform_expressions_in_expression(config.as_mut(), transform, flags);
2159+
}
2160+
if let Some(ref mut config) = op.placeholder_config {
2161+
transform_expressions_in_expression(config.as_mut(), transform, flags);
2162+
}
2163+
}
21502164
// Operations without expressions (expressions are in the UPDATE op now)
21512165
CreateOp::Conditional(_) => {}
21522166
// Operations without expressions
@@ -2162,7 +2176,6 @@ pub fn transform_expressions_in_create_op<'a, F>(
21622176
| CreateOp::EnableBindings(_)
21632177
| CreateOp::Text(_)
21642178
| CreateOp::Pipe(_)
2165-
| CreateOp::Defer(_)
21662179
| CreateOp::I18nMessage(_)
21672180
| CreateOp::Namespace(_)
21682181
| CreateOp::ProjectionDef(_)
@@ -2329,6 +2342,15 @@ pub fn visit_expressions_in_create_op<'a, F>(
23292342
}
23302343
}
23312344
}
2345+
// Defer: visit loadingConfig and placeholderConfig expressions.
2346+
CreateOp::Defer(op) => {
2347+
if let Some(ref config) = op.loading_config {
2348+
visit_expressions_in_expression(config.as_ref(), visitor, flags);
2349+
}
2350+
if let Some(ref config) = op.placeholder_config {
2351+
visit_expressions_in_expression(config.as_ref(), visitor, flags);
2352+
}
2353+
}
23322354
// Operations without expressions
23332355
CreateOp::Conditional(_)
23342356
| CreateOp::ListEnd(_)
@@ -2343,7 +2365,6 @@ pub fn visit_expressions_in_create_op<'a, F>(
23432365
| CreateOp::EnableBindings(_)
23442366
| CreateOp::Text(_)
23452367
| CreateOp::Pipe(_)
2346-
| CreateOp::Defer(_)
23472368
| CreateOp::I18nMessage(_)
23482369
| CreateOp::Namespace(_)
23492370
| CreateOp::ProjectionDef(_)

crates/oxc_angular_compiler/src/ir/ops.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,12 +1017,12 @@ pub struct DeferOp<'a> {
10171017
pub loading_minimum_time: Option<u32>,
10181018
/// Loading after time.
10191019
pub loading_after_time: Option<u32>,
1020-
/// Placeholder config const index (points to [minimumTime] in consts array).
1021-
/// Set by defer_configs phase after adding to constant pool.
1022-
pub placeholder_config: Option<u32>,
1023-
/// Loading config const index (points to [minimumTime, afterTime] in consts array).
1024-
/// Set by defer_configs phase after adding to constant pool.
1025-
pub loading_config: Option<u32>,
1020+
/// Placeholder config expression (wraps [minimumTime] as ConstCollectedExpr).
1021+
/// Set by defer_configs phase; resolved to ConstReference by const collection phase.
1022+
pub placeholder_config: Option<Box<'a, IrExpression<'a>>>,
1023+
/// Loading config expression (wraps [minimumTime, afterTime] as ConstCollectedExpr).
1024+
/// Set by defer_configs phase; resolved to ConstReference by const collection phase.
1025+
pub loading_config: Option<Box<'a, IrExpression<'a>>>,
10261026
/// Resolver function expression (after constant pool processing).
10271027
/// This is the shared function reference created by resolve_defer_deps_fns.
10281028
/// Corresponds to `resolverFn` in Angular TS.

crates/oxc_angular_compiler/src/pipeline/phases/defer_configs.rs

Lines changed: 109 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@
33
//! Configures @defer block instructions with trigger and dependency information.
44
//!
55
//! This phase processes DeferOp and DeferOnOp to:
6-
//! 1. Collect timing configs into the constant pool
6+
//! 1. Wrap timing configs in ConstCollectedExpr (resolved to consts later by Phase 53)
77
//! 2. Link defer triggers to their target defer blocks
88
//! 3. Set up main, placeholder, loading, and error template slots
99
//! 4. Configure timing parameters (minimum time, loading after, etc.)
1010
//!
1111
//! Ported from Angular's `template/pipeline/src/phases/defer_configs.ts`.
12+
//!
13+
//! Key difference from old approach: instead of calling `job.add_const()` here (which
14+
//! places timer configs before i18n consts), we create ConstCollectedExpr wrappers.
15+
//! These are resolved by Phase 53 (collectConstExpressions) which runs AFTER Phase 52
16+
//! (collectI18nConsts), ensuring correct const array ordering.
1217
13-
use oxc_allocator::Vec as OxcVec;
18+
use oxc_allocator::{Box, Vec as OxcVec};
1419

20+
use crate::ast::expression::{AbsoluteSourceSpan, LiteralPrimitive, LiteralValue, ParseSpan};
1521
use crate::ir::enums::DeferOpModifierKind;
22+
use crate::ir::expression::{ConstCollectedExpr, IrExpression, IrLiteralArrayExpr};
1623
use crate::ir::ops::{CreateOp, UpdateOp, XrefId};
17-
use crate::pipeline::compilation::{ComponentCompilationJob, ConstValue};
24+
use crate::pipeline::compilation::ComponentCompilationJob;
1825

1926
/// Collected timing config for a defer block.
2027
#[derive(Clone)]
@@ -28,7 +35,7 @@ struct DeferTimingConfig {
2835
/// Configures defer instructions with trigger and dependency information.
2936
///
3037
/// This phase:
31-
/// 1. Collects timing configs and adds them to the constant pool
38+
/// 1. Collects timing configs and wraps them in ConstCollectedExpr for later resolution
3239
/// 2. Collects all DeferOp blocks and their associated DeferOnOp triggers
3340
/// 3. Links triggers to their target defer blocks
3441
/// 4. Validates timing parameters
@@ -54,51 +61,123 @@ pub fn configure_defer_instructions(job: &mut ComponentCompilationJob<'_>) {
5461
})
5562
.collect();
5663

57-
// Create const pool entries for timing configs
58-
// Map: xref -> (placeholder_config_index, loading_config_index)
59-
let mut config_indices: std::vec::Vec<(XrefId, Option<u32>, Option<u32>)> = Vec::new();
64+
// Build ConstCollectedExpr wrappers for each defer block's timing configs.
65+
// These wrap a LiteralArray (e.g. [100, null]) in ConstCollectedExpr so that
66+
// Phase 53 (collectConstExpressions) will lift them to consts AFTER i18n consts.
67+
//
68+
// This matches Angular TS's defer_configs.ts which uses:
69+
// op.loadingConfig = new ir.ConstCollectedExpr(literalOrArrayLiteral([...]))
70+
let mut config_exprs: std::vec::Vec<(
71+
XrefId,
72+
Option<Box<'_, IrExpression<'_>>>,
73+
Option<Box<'_, IrExpression<'_>>>,
74+
)> = Vec::new();
75+
76+
let span = ParseSpan { start: 0, end: 0 };
77+
let source_span = AbsoluteSourceSpan { start: 0, end: 0 };
6078

6179
for config in &timing_configs {
62-
let mut placeholder_config_idx = None;
63-
let mut loading_config_idx = None;
80+
let mut placeholder_config_expr = None;
81+
let mut loading_config_expr = None;
6482

6583
// Create loading config: [minimumTime, afterTime]
66-
// Note: Angular processes loadingConfig before placeholderConfig in transformExpressionsInOp
67-
// (see ir/src/expression.ts lines 1177-1186), so we must add loading config first
68-
// to get the correct const pool index order.
84+
// Angular processes loadingConfig before placeholderConfig in transformExpressionsInOp
85+
// (see ir/src/expression.ts lines 1241-1251), so we create loading config first.
86+
// Angular uses `literalOrArrayLiteral([op.loadingMinimumTime, op.loadingAfterTime])`
87+
// which emits `null` for missing values, not `0`.
6988
if config.loading_minimum_time.is_some() || config.loading_after_time.is_some() {
70-
let min_time = config.loading_minimum_time.unwrap_or(0);
71-
let after_time = config.loading_after_time.unwrap_or(0);
72-
let mut entries = OxcVec::new_in(allocator);
73-
entries.push(ConstValue::Number(min_time as f64));
74-
entries.push(ConstValue::Number(after_time as f64));
75-
let const_value = ConstValue::Array(entries);
76-
loading_config_idx = Some(job.add_const(const_value));
89+
let mut elements = OxcVec::with_capacity_in(2, allocator);
90+
91+
// minimumTime: number or null
92+
let min_val = match config.loading_minimum_time {
93+
Some(t) => LiteralValue::Number(t as f64),
94+
None => LiteralValue::Null,
95+
};
96+
elements.push(IrExpression::Ast(Box::new_in(
97+
crate::ast::expression::AngularExpression::LiteralPrimitive(Box::new_in(
98+
LiteralPrimitive { span, source_span, value: min_val },
99+
allocator,
100+
)),
101+
allocator,
102+
)));
103+
104+
// afterTime: number or null
105+
let after_val = match config.loading_after_time {
106+
Some(t) => LiteralValue::Number(t as f64),
107+
None => LiteralValue::Null,
108+
};
109+
elements.push(IrExpression::Ast(Box::new_in(
110+
crate::ast::expression::AngularExpression::LiteralPrimitive(Box::new_in(
111+
LiteralPrimitive { span, source_span, value: after_val },
112+
allocator,
113+
)),
114+
allocator,
115+
)));
116+
117+
let array_expr = IrExpression::LiteralArray(Box::new_in(
118+
IrLiteralArrayExpr { elements, source_span: None },
119+
allocator,
120+
));
121+
122+
loading_config_expr = Some(Box::new_in(
123+
IrExpression::ConstCollected(Box::new_in(
124+
ConstCollectedExpr {
125+
expr: Box::new_in(array_expr, allocator),
126+
source_span: None,
127+
},
128+
allocator,
129+
)),
130+
allocator,
131+
));
77132
}
78133

79134
// Create placeholder config: [minimumTime]
80135
if let Some(min_time) = config.placeholder_minimum_time {
81-
let mut entries = OxcVec::new_in(allocator);
82-
entries.push(ConstValue::Number(min_time as f64));
83-
let const_value = ConstValue::Array(entries);
84-
placeholder_config_idx = Some(job.add_const(const_value));
136+
let mut elements = OxcVec::with_capacity_in(1, allocator);
137+
elements.push(IrExpression::Ast(Box::new_in(
138+
crate::ast::expression::AngularExpression::LiteralPrimitive(Box::new_in(
139+
LiteralPrimitive {
140+
span,
141+
source_span,
142+
value: LiteralValue::Number(min_time as f64),
143+
},
144+
allocator,
145+
)),
146+
allocator,
147+
)));
148+
149+
let array_expr = IrExpression::LiteralArray(Box::new_in(
150+
IrLiteralArrayExpr { elements, source_span: None },
151+
allocator,
152+
));
153+
154+
placeholder_config_expr = Some(Box::new_in(
155+
IrExpression::ConstCollected(Box::new_in(
156+
ConstCollectedExpr {
157+
expr: Box::new_in(array_expr, allocator),
158+
source_span: None,
159+
},
160+
allocator,
161+
)),
162+
allocator,
163+
));
85164
}
86165

87-
config_indices.push((config.xref, placeholder_config_idx, loading_config_idx));
166+
config_exprs.push((config.xref, placeholder_config_expr, loading_config_expr));
88167
}
89168

90-
// Update DeferOp with config indices
169+
// Update DeferOp with config expressions
91170
let view_xrefs: std::vec::Vec<XrefId> = job.all_views().map(|v| v.xref).collect();
92171
for view_xref in view_xrefs {
93172
if let Some(view) = job.view_mut(view_xref) {
94173
for op in view.create.iter_mut() {
95174
if let CreateOp::Defer(defer) = op {
96-
// Find the config indices for this defer block
97-
if let Some((_, placeholder_idx, loading_idx)) =
98-
config_indices.iter().find(|(xref, _, _)| *xref == defer.xref)
175+
// Find the config expressions for this defer block
176+
if let Some((_, placeholder_expr, loading_expr)) =
177+
config_exprs.iter_mut().find(|(xref, _, _)| *xref == defer.xref)
99178
{
100-
defer.placeholder_config = *placeholder_idx;
101-
defer.loading_config = *loading_idx;
179+
defer.placeholder_config = placeholder_expr.take();
180+
defer.loading_config = loading_expr.take();
102181
}
103182
}
104183
}

crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,23 @@ fn reify_create_op<'a>(
507507
CreateOp::Defer(defer) => {
508508
// Emit defer instruction for @defer
509509
let slot = defer.slot.map(|s| s.0).unwrap_or(0);
510-
// Take ownership of resolver_fn since OutputExpression doesn't implement Clone
511-
// Use config indices (const pool slots) instead of raw timing values
510+
// Extract const indices from resolved ConstReference expressions.
511+
// By this point, Phase 53 (collectConstExpressions) has replaced
512+
// ConstCollectedExpr with ConstReference(index).
513+
let loading_config = defer.loading_config.as_ref().and_then(|expr| {
514+
if let crate::ir::expression::IrExpression::ConstReference(cr) = expr.as_ref() {
515+
Some(cr.index)
516+
} else {
517+
None
518+
}
519+
});
520+
let placeholder_config = defer.placeholder_config.as_ref().and_then(|expr| {
521+
if let crate::ir::expression::IrExpression::ConstReference(cr) = expr.as_ref() {
522+
Some(cr.index)
523+
} else {
524+
None
525+
}
526+
});
512527
Some(create_defer_stmt(
513528
allocator,
514529
slot,
@@ -517,8 +532,8 @@ fn reify_create_op<'a>(
517532
defer.loading_slot.map(|s| s.0),
518533
defer.placeholder_slot.map(|s| s.0),
519534
defer.error_slot.map(|s| s.0),
520-
defer.loading_config,
521-
defer.placeholder_config,
535+
loading_config,
536+
placeholder_config,
522537
defer.flags,
523538
))
524539
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4619,3 +4619,49 @@ fn test_i18n_nested_icu_with_interpolations_inside_elements() {
46194619

46204620
insta::assert_snapshot!("i18n_nested_icu_with_interpolations_inside_elements", js);
46214621
}
4622+
4623+
/// Tests that @defer loading timer consts are ordered AFTER i18n consts in the consts array.
4624+
///
4625+
/// Angular's TS compiler wraps defer timer configs in ConstCollectedExpr (phase 19), which are
4626+
/// resolved later in collectConstExpressions (phase 53) — AFTER i18n consts are added (phase 52).
4627+
/// This means i18n consts always appear before defer timer consts in the consts array.
4628+
///
4629+
/// Previously, OXC directly called job.add_const() in the defer_configs phase, placing the timer
4630+
/// const [100, null] at the front of the array and shifting all i18n indices by +1.
4631+
///
4632+
/// The template pattern: an i18n message + @defer with @loading(minimum 100ms).
4633+
/// Expected consts ordering: [i18n_0, [100, null], ...]
4634+
/// Bug consts ordering: [[100, 0], i18n_0, ...]
4635+
#[test]
4636+
fn test_defer_loading_timer_consts_after_i18n_consts() {
4637+
let js = compile_template_to_js(
4638+
r#"<span i18n="@@my-label">Hello</span>
4639+
@defer (on viewport; prefetch on idle) {
4640+
<div>Deferred content</div>
4641+
} @loading (minimum 100ms) {
4642+
<div>Loading...</div>
4643+
}"#,
4644+
"TestComponent",
4645+
);
4646+
4647+
// The i18n message should reference const index 0 (i18n_0 is first in consts array)
4648+
// The @defer instruction should reference the timer config at a later index
4649+
//
4650+
// NG expected output has:
4651+
// consts: [...] => return [i18n_0, [100, null], ...]
4652+
// i18n(N, 0) — i18n at const index 0
4653+
// defer(M, ..., 1, ..., timerScheduling) — timer config at const index 1
4654+
//
4655+
// The bug would produce:
4656+
// consts: [...] => return [[100, 0], i18n_0, ...]
4657+
// i18n(N, 1) — i18n at const index 1 (wrong!)
4658+
// defer(M, ..., 0, ..., timerScheduling) — timer config at const index 0 (wrong!)
4659+
4660+
// Check that i18n references const index 0 (not 1)
4661+
assert!(
4662+
js.contains("i18n(1,0)") || js.contains("i18n(1, 0)"),
4663+
"i18n should reference const index 0 (before defer timer const). Output:\n{js}"
4664+
);
4665+
4666+
insta::assert_snapshot!("defer_loading_timer_consts_after_i18n_consts", js);
4667+
}
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 TestComponent_Defer_3_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵtext(0,"\n ");
8+
i0.ɵɵelementStart(1,"div");
9+
i0.ɵɵtext(2,"Deferred content");
10+
i0.ɵɵelementEnd();
11+
i0.ɵɵtext(3,"\n");
12+
}
13+
}
14+
function TestComponent_DeferLoading_4_Template(rf,ctx) {
15+
if ((rf & 1)) {
16+
i0.ɵɵtext(0,"\n ");
17+
i0.ɵɵelementStart(1,"div");
18+
i0.ɵɵtext(2,"Loading...");
19+
i0.ɵɵelementEnd();
20+
i0.ɵɵtext(3,"\n");
21+
}
22+
}
23+
function TestComponent_Template(rf,ctx) {
24+
if ((rf & 1)) {
25+
i0.ɵɵelementStart(0,"span");
26+
i0.ɵɵi18n(1,0);
27+
i0.ɵɵelementEnd();
28+
i0.ɵɵtext(2,"\n");
29+
i0.ɵɵdomTemplate(3,TestComponent_Defer_3_Template,4,0)(4,TestComponent_DeferLoading_4_Template,
30+
4,0);
31+
i0.ɵɵdefer(5,3,null,4,null,null,1,null,i0.ɵɵdeferEnableTimerScheduling);
32+
i0.ɵɵdeferOnViewport();
33+
i0.ɵɵdeferPrefetchOnIdle();
34+
}
35+
}

0 commit comments

Comments
 (0)