Skip to content

Commit 7a713e8

Browse files
Brooooooklynclaude
andcommitted
fix(angular): fix nested @for track expression with outer-scope variable
The Rust resolve_names phase was merging update-scope variables into the create-scope, making @for loop variables (like $implicit) visible when resolving RepeaterCreate.track expressions. Angular TypeScript processes create and update ops as separate lexical scopes. This caused track expressions referencing outer @for variables to resolve incorrectly, producing broken output with out-of-scope identifiers instead of generating `this`-based track functions with usesComponentInstance=true. Three fixes applied: - resolve_names: stop merging update scope into create scope, process RepeaterCreate.track_by_ops with their own scope - resolve_contexts: process view.functions (arrow fn ops) and RepeaterCreate.track_by_ops - reify: handle track_by_ops in reify_track_by, generating proper function vs arrow forms based on usesComponentInstance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 52b25dc commit 7a713e8

5 files changed

Lines changed: 239 additions & 92 deletions

File tree

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

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,8 @@ fn reify_create_op<'a>(
480480
&repeater.track,
481481
repeater.track_fn_name.as_ref(),
482482
repeater.uses_component_instance,
483+
&mut repeater.track_by_ops,
484+
diagnostics,
483485
);
484486

485487
Some(create_repeater_create_stmt_with_track_expr(
@@ -1171,7 +1173,12 @@ fn reify_host_create_op<'a>(
11711173
/// the track expression as the body, registers it with the constant pool,
11721174
/// and returns the function reference.
11731175
///
1176+
/// When `track_by_ops` is `Some`, the ops are reified into statements and assembled
1177+
/// into a function body. This handles cases where the track expression needs additional
1178+
/// context variable declarations (e.g., `nextContext()` calls for outer-scope access).
1179+
///
11741180
/// Ported from Angular's `reifyTrackBy()` in `reify.ts`.
1181+
#[allow(clippy::too_many_arguments)]
11751182
fn reify_track_by<'a>(
11761183
allocator: &'a oxc_allocator::Allocator,
11771184
pool: &mut ConstantPool<'a>,
@@ -1180,6 +1187,8 @@ fn reify_track_by<'a>(
11801187
track: &IrExpression<'a>,
11811188
track_fn_name: Option<&Atom<'a>>,
11821189
uses_component_instance: bool,
1190+
track_by_ops: &mut Option<oxc_allocator::Vec<'a, UpdateOp<'a>>>,
1191+
diagnostics: &mut Vec<OxcDiagnostic>,
11831192
) -> OutputExpression<'a> {
11841193
// If the tracking function was already set by optimization phase, return a reference to it
11851194
if let Some(fn_name) = track_fn_name {
@@ -1279,42 +1288,99 @@ fn reify_track_by<'a>(
12791288
));
12801289
}
12811290

1282-
// Convert the track expression to output expression
1283-
let track_body = convert_ir_expression(allocator, track, expressions, root_xref);
1284-
12851291
// Create the track function with params ($index, $item)
1286-
// If uses_component_instance is true, use a regular function expression so `this` is bound correctly.
1287-
// Otherwise, use an arrow function.
1288-
// Both are registered with the constant pool via getSharedFunctionReference,
1289-
// which maintains insertion order in pool.statements.
12901292
let mut params = OxcVec::with_capacity_in(2, allocator);
12911293
params.push(FnParam { name: Atom::from("$index") });
12921294
params.push(FnParam { name: Atom::from("$item") });
12931295

1294-
let fn_expr = if uses_component_instance {
1295-
// Regular function expression: function($index, $item) { return track_body; }
1296-
// Angular uses o.fn(params, [new o.ReturnStatement(op.track)])
1297-
let mut stmts = OxcVec::with_capacity_in(1, allocator);
1298-
stmts.push(OutputStatement::Return(Box::new_in(
1299-
ReturnStatement { value: track_body, source_span: None },
1300-
allocator,
1301-
)));
1296+
let fn_expr = if let Some(track_ops) = track_by_ops {
1297+
// Complex case: track_by_ops is present (set by track_fn_optimization phase).
1298+
// This happens when the track expression needs additional ops like context
1299+
// variable declarations (e.g., `const group_r2 = nextContext().$implicit`).
1300+
//
1301+
// Ported from Angular's reify.ts lines 884-904:
1302+
// reifyUpdateOperations(unit, op.trackByOps);
1303+
// const statements = [...]; // from trackByOps
1304+
// fn = op.usesComponentInstance || statements.length !== 1 || !(statements[0] instanceof ReturnStatement)
1305+
// ? o.fn(params, statements)
1306+
// : o.arrowFn(params, statements[0].value);
13021307

1303-
OutputExpression::Function(Box::new_in(
1304-
FunctionExpr { name: None, params, statements: stmts, source_span: None },
1305-
allocator,
1306-
))
1308+
// Reify each op in track_by_ops into output statements
1309+
let mut statements = OxcVec::new_in(allocator);
1310+
for track_op in track_ops.iter() {
1311+
if let Some(stmt) = reify_update_op(
1312+
allocator,
1313+
track_op,
1314+
expressions,
1315+
root_xref,
1316+
TemplateCompilationMode::Full,
1317+
diagnostics,
1318+
) {
1319+
statements.push(stmt);
1320+
}
1321+
}
1322+
1323+
// Determine whether to use function or arrow:
1324+
// Angular uses function when:
1325+
// - usesComponentInstance is true, OR
1326+
// - there are multiple statements, OR
1327+
// - the single statement is not a ReturnStatement
1328+
let use_function = uses_component_instance
1329+
|| statements.len() != 1
1330+
|| !matches!(statements.first(), Some(OutputStatement::Return(_)));
1331+
1332+
if use_function {
1333+
OutputExpression::Function(Box::new_in(
1334+
FunctionExpr { name: None, params, statements, source_span: None },
1335+
allocator,
1336+
))
1337+
} else {
1338+
// Single return statement → extract value for arrow function body
1339+
// Clone the return value since we can't move out of the Box
1340+
let return_value = if let Some(OutputStatement::Return(ret)) = statements.first() {
1341+
ret.value.clone_in(allocator)
1342+
} else {
1343+
unreachable!("checked above that there's exactly one Return statement");
1344+
};
1345+
1346+
OutputExpression::ArrowFunction(Box::new_in(
1347+
ArrowFunctionExpr {
1348+
params,
1349+
body: ArrowFunctionBody::Expression(Box::new_in(return_value, allocator)),
1350+
source_span: None,
1351+
},
1352+
allocator,
1353+
))
1354+
}
13071355
} else {
1308-
// Arrow function: ($index, $item) => track_body
1309-
// Angular uses o.arrowFn(params, op.track)
1310-
OutputExpression::ArrowFunction(Box::new_in(
1311-
ArrowFunctionExpr {
1312-
params,
1313-
body: ArrowFunctionBody::Expression(Box::new_in(track_body, allocator)),
1314-
source_span: None,
1315-
},
1316-
allocator,
1317-
))
1356+
// Simple case: no track_by_ops. Wrap the raw track expression.
1357+
// Ported from Angular's reify.ts lines 878-883:
1358+
// fn = op.usesComponentInstance
1359+
// ? o.fn(params, [new o.ReturnStatement(op.track)])
1360+
// : o.arrowFn(params, op.track);
1361+
let track_body = convert_ir_expression(allocator, track, expressions, root_xref);
1362+
1363+
if uses_component_instance {
1364+
let mut stmts = OxcVec::with_capacity_in(1, allocator);
1365+
stmts.push(OutputStatement::Return(Box::new_in(
1366+
ReturnStatement { value: track_body, source_span: None },
1367+
allocator,
1368+
)));
1369+
1370+
OutputExpression::Function(Box::new_in(
1371+
FunctionExpr { name: None, params, statements: stmts, source_span: None },
1372+
allocator,
1373+
))
1374+
} else {
1375+
OutputExpression::ArrowFunction(Box::new_in(
1376+
ArrowFunctionExpr {
1377+
params,
1378+
body: ArrowFunctionBody::Expression(Box::new_in(track_body, allocator)),
1379+
source_span: None,
1380+
},
1381+
allocator,
1382+
))
1383+
}
13181384
};
13191385

13201386
// Register with constant pool as a shared function

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ pub fn resolve_contexts(job: &mut ComponentCompilationJob<'_>) {
4242
for view_xref in view_xrefs {
4343
let is_root = view_xref == root_xref;
4444
if let Some(view) = job.view_mut(view_xref) {
45+
// Process arrow functions' ops first, matching Angular's resolve_contexts.ts:
46+
// for (const expr of unit.functions) { processLexicalScope(unit, expr.ops); }
47+
for fn_ptr in view.functions.iter() {
48+
// SAFETY: The pointer is valid because it was populated by generate_arrow_functions
49+
// and the allocator keeps the data alive.
50+
let arrow_fn = unsafe { &mut **fn_ptr };
51+
process_lexical_scope_update_vec(allocator, view_xref, is_root, &mut arrow_fn.ops);
52+
}
53+
4554
process_lexical_scope_create(allocator, view_xref, is_root, &mut view.create);
4655
process_lexical_scope_update(allocator, view_xref, is_root, &mut view.update);
4756
}
@@ -79,7 +88,8 @@ fn process_lexical_scope_create<'a>(
7988
}
8089

8190
// Second pass: Transform ContextExpr to the appropriate expression
82-
// Also recursively process listener handler_ops (per resolve_contexts.ts lines 45-49)
91+
// Also recursively process listener handler_ops and RepeaterCreate track_by_ops
92+
// (per resolve_contexts.ts lines 40-61)
8393
for op in ops.iter_mut() {
8494
match op {
8595
CreateOp::Listener(listener) => {
@@ -122,6 +132,26 @@ fn process_lexical_scope_create<'a>(
122132
&mut None,
123133
);
124134
}
135+
CreateOp::RepeaterCreate(repeater) => {
136+
// Process track_by_ops with their own scope first, matching Angular's
137+
// resolve_contexts.ts lines 55-58:
138+
// case ir.OpKind.RepeaterCreate:
139+
// if (op.trackByOps !== null) { processLexicalScope(view, op.trackByOps); }
140+
if let Some(ref mut track_by_ops) = repeater.track_by_ops {
141+
process_lexical_scope_update_vec(allocator, view_xref, is_root, track_by_ops);
142+
}
143+
// Also transform expressions in the RepeaterCreate op itself (e.g., rep.track)
144+
// using the parent scope. Note: track_by_ops have already been processed above
145+
// so re-visiting them via transform_expressions_in_create_op is a no-op
146+
// (ContextExpr nodes in track_by_ops are already resolved).
147+
transform_expressions_in_create_op(
148+
op,
149+
&|expr, _flags| {
150+
transform_context_expr(allocator, expr, &scope);
151+
},
152+
VisitorContextFlag::NONE,
153+
);
154+
}
125155
_ => {
126156
transform_expressions_in_create_op(
127157
op,
@@ -251,6 +281,50 @@ fn process_lexical_scope_update<'a>(
251281
}
252282
}
253283

284+
/// Process update operations in a Vec (used for arrow function ops and track_by_ops).
285+
///
286+
/// This is the same logic as `process_lexical_scope_update` but works with `Vec<UpdateOp>`
287+
/// instead of `UpdateOpList`. Needed for `ArrowFunctionExpr.ops` and `RepeaterCreate.track_by_ops`.
288+
fn process_lexical_scope_update_vec<'a>(
289+
allocator: &'a oxc_allocator::Allocator,
290+
view_xref: XrefId,
291+
is_root: bool,
292+
ops: &mut oxc_allocator::Vec<'a, UpdateOp<'a>>,
293+
) {
294+
// Track how to access each view's context by its XrefId.
295+
let mut scope: FxHashMap<XrefId, ContextAccess> = FxHashMap::default();
296+
297+
// The current view's context is accessible via the `ctx` parameter.
298+
scope.insert(view_xref, ContextAccess::CtxParameter);
299+
300+
// First pass: Build scope from Variable operations
301+
for op in ops.iter() {
302+
if let UpdateOp::Variable(var_op) = op {
303+
if var_op.kind == SemanticVariableKind::Context {
304+
if let Some(target_view) = var_op.view {
305+
scope.insert(target_view, ContextAccess::ReadVariable(var_op.xref));
306+
}
307+
}
308+
}
309+
}
310+
311+
// If this is the root view, prefer `ctx` over any variables
312+
if is_root {
313+
scope.insert(view_xref, ContextAccess::CtxParameter);
314+
}
315+
316+
// Second pass: Transform ContextExpr to the appropriate expression
317+
for op in ops.iter_mut() {
318+
transform_expressions_in_update_op(
319+
op,
320+
&|expr, _flags| {
321+
transform_context_expr(allocator, expr, &scope);
322+
},
323+
VisitorContextFlag::NONE,
324+
);
325+
}
326+
}
327+
254328
/// Transforms a ContextExpr based on the scope.
255329
///
256330
/// - For current view context: Keep as ContextExpr (reify handles ctx)

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

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,19 @@ pub fn resolve_names(job: &mut ComponentCompilationJob<'_>) {
5050
// Collect expression store data we need first
5151
let expression_store_ptr = &job.expressions as *const ExpressionStore<'_>;
5252

53-
// Process each view's create and update operation lists
54-
// IMPORTANT: We need to build scope from BOTH create and update ops before processing listeners,
55-
// because listener handler expressions need access to variables from both phases.
53+
// Process each view's create and update operation lists.
54+
// Per Angular's TypeScript (resolve_names.ts lines 25-26), create and update ops are
55+
// processed as SEPARATE lexical scopes — each builds its own scope/localDefinitions
56+
// from its own Variable ops. This is important because variables from update ops
57+
// (like @for loop items: $implicit, $index) should NOT be visible when resolving
58+
// expressions in create ops (like RepeaterCreate.track). The generateVariables phase
59+
// already prepends all necessary variables to each op list.
5660
for view in job.all_views_mut() {
5761
// SAFETY: We're only reading from expression_store, not modifying it
5862
let expressions = unsafe { &*expression_store_ptr };
5963

60-
// Build scope from update ops first (contains context variables like $implicit, $index, etc.)
61-
let update_scope = build_scope_from_update_ops(&view.update);
62-
63-
// Process create ops with the update scope available for listener handler expressions
64-
process_lexical_scope_create(
65-
root_xref,
66-
&mut view.create,
67-
None,
68-
&update_scope,
69-
allocator,
70-
expressions,
71-
);
64+
// Process create ops with their own scope (no update scope merged in)
65+
process_lexical_scope_create(root_xref, &mut view.create, None, allocator, expressions);
7266
process_lexical_scope_update(root_xref, &mut view.update, None, allocator, expressions);
7367
}
7468

@@ -139,50 +133,17 @@ where
139133
maps
140134
}
141135

142-
/// Build scope maps from update operations (for variables like context reads).
143-
fn build_scope_from_update_ops<'a>(ops: &crate::ir::list::UpdateOpList<'a>) -> ScopeMaps<'a> {
144-
let mut maps = ScopeMaps::default();
145-
for op in ops.iter() {
146-
if let UpdateOp::Variable(var_op) = op {
147-
match var_op.kind {
148-
SemanticVariableKind::Identifier => {
149-
// Check if this is a local variable (@let declaration)
150-
if var_op.local {
151-
if !maps.local_definitions.contains_key(&var_op.name) {
152-
maps.local_definitions.insert(var_op.name.clone(), var_op.xref);
153-
}
154-
} else if !maps.scope.contains_key(&var_op.name) {
155-
maps.scope.insert(var_op.name.clone(), var_op.xref);
156-
}
157-
// Also add to scope for non-local (always)
158-
if !maps.scope.contains_key(&var_op.name) {
159-
maps.scope.insert(var_op.name.clone(), var_op.xref);
160-
}
161-
}
162-
SemanticVariableKind::Alias => {
163-
if !maps.scope.contains_key(&var_op.name) {
164-
maps.scope.insert(var_op.name.clone(), var_op.xref);
165-
}
166-
}
167-
_ => {}
168-
}
169-
}
170-
}
171-
maps
172-
}
173-
174136
/// Process create operations to build scope and resolve names.
175137
fn process_lexical_scope_create<'a>(
176138
root_xref: XrefId,
177139
ops: &mut crate::ir::list::CreateOpList<'a>,
178140
saved_view: Option<SavedView>,
179-
update_scope: &ScopeMaps<'a>,
180141
allocator: &'a oxc_allocator::Allocator,
181142
expressions: &ExpressionStore<'a>,
182143
) {
183144
// Maps variable names to their XrefIds
184-
// Start with update_scope to include context variables (like @for loop items)
185-
let mut scope: ScopeMaps<'a> = update_scope.clone();
145+
// Per Angular's TypeScript, each processLexicalScope call starts with a fresh scope.
146+
let mut scope: ScopeMaps<'a> = ScopeMaps::default();
186147

187148
// Track saved view for RestoreView expressions
188149
let mut current_saved_view = saved_view;
@@ -335,6 +296,12 @@ fn process_lexical_scope_create<'a>(
335296
}
336297
}
337298
_ => {
299+
// Note: RepeaterCreate falls through here. Angular's resolve_names.ts first
300+
// pass (lines 88-92) processes trackByOps with its own scope, but trackByOps
301+
// is always None at this phase (phase 31) — it's created later by
302+
// track_fn_optimization (phase 34). The second pass processes all ops
303+
// (including RepeaterCreate) via transformExpressionsInOp with the parent
304+
// scope, which is what transform_expressions_in_create_op does here.
338305
transform_expressions_in_create_op(
339306
op,
340307
&|expr, _flags| {
@@ -1521,18 +1488,8 @@ pub fn resolve_names_for_host(job: &mut HostBindingCompilationJob<'_>) {
15211488
// SAFETY: We're only reading from expression_store, not modifying it
15221489
let expressions = unsafe { &*expression_store_ptr };
15231490

1524-
// Build scope from update ops
1525-
let update_scope = build_scope_from_update_ops(&job.root.update);
1526-
1527-
// Process create ops with the update scope available
1528-
process_lexical_scope_create(
1529-
root_xref,
1530-
&mut job.root.create,
1531-
None,
1532-
&update_scope,
1533-
allocator,
1534-
expressions,
1535-
);
1491+
// Process create ops with their own scope (no update scope merged in)
1492+
process_lexical_scope_create(root_xref, &mut job.root.create, None, allocator, expressions);
15361493
process_lexical_scope_update(root_xref, &mut job.root.update, None, allocator, expressions);
15371494

15381495
// Verify no LexicalRead expressions remain after resolution.

0 commit comments

Comments
 (0)