Skip to content

Commit 3ba680f

Browse files
fix(compiler): detect component context in compound track expressions (#172)
Track expressions using binary operators (??. +), ternary, or unary operators that referenced component members were incorrectly compiled as arrow functions, causing `this` to be undefined at runtime. The root cause was that `expression_contains_context` — a function not present in Angular's implementation — failed to traverse compound IR expression types like Binary and Ternary. Instead of patching every missing variant, this commit aligns with Angular's approach: detect ContextExpr during the transformExpressionsInExpression callback that already visits all expression types, setting usesComponentInstance in the same pass that replaces Context with TrackContext. This removes ~180 lines of redundant detection code (expression_contains_context, ast_contains_implicit_receiver) that duplicated work already done by resolve_names (phase 31) and the transform visitor. Output verified against the official Angular compiler (NgtscProgram) for all test cases. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dcb536a commit 3ba680f

9 files changed

+369
-182
lines changed

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

Lines changed: 17 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,24 @@ fn optimize_track_expression<'a>(
9494
// to the non-optimizable case below, which creates a wrapper function like:
9595
// `function _forTrack($index,$item) { return this.trackByFn; }`
9696

97-
// First check if the expression contains any ContextExpr or AST expressions
98-
// that reference the component instance (implicit receiver)
99-
let has_context = expression_contains_context(&rep.track, expressions);
100-
if has_context {
101-
rep.uses_component_instance = true;
102-
}
103-
104-
// For non-optimizable tracks, replace ContextExpr with TrackContextExpr
105-
// This signals that context reads in track expressions need special handling
97+
// The track function could not be optimized.
98+
// Replace context reads with TrackContextExpr, since context reads in a track
99+
// function are emitted specially (as `this` instead of `ctx`).
100+
//
101+
// Following Angular's implementation (track_fn_optimization.ts:54-70), we set
102+
// usesComponentInstance inside the transform callback when a ContextExpr is found.
103+
// This is the authoritative detection — transformExpressionsInExpression traverses
104+
// all expression variants, so no ContextExpr can be missed regardless of nesting.
105+
//
106+
// By phase 34 (this phase), resolve_names (phase 31) has already converted all
107+
// ImplicitReceiver AST nodes into Context IR expressions, so checking for Context
108+
// during the transform is sufficient.
109+
let found_context = std::cell::Cell::new(false);
106110
transform_expressions_in_expression(
107111
&mut rep.track,
108112
&|expr, _flags| {
109113
if let IrExpression::Context(ctx) = expr {
114+
found_context.set(true);
110115
*expr = IrExpression::TrackContext(oxc_allocator::Box::new_in(
111116
TrackContextExpr { view: ctx.view, source_span: None },
112117
allocator,
@@ -115,6 +120,9 @@ fn optimize_track_expression<'a>(
115120
},
116121
VisitorContextFlag::NONE,
117122
);
123+
if found_context.get() {
124+
rep.uses_component_instance = true;
125+
}
118126

119127
// Also create an op list for the tracking expression since it may need
120128
// additional ops when generating the final code (e.g. temporary variables).
@@ -146,179 +154,6 @@ fn optimize_track_expression<'a>(
146154
rep.track_by_ops = Some(track_by_ops);
147155
}
148156

149-
/// Check if an expression contains any Context expressions or AST expressions that
150-
/// reference the component instance (implicit receiver).
151-
fn expression_contains_context(
152-
expr: &IrExpression<'_>,
153-
expressions: &crate::pipeline::expression_store::ExpressionStore<'_>,
154-
) -> bool {
155-
match expr {
156-
IrExpression::Context(_) => true,
157-
// Check AST expressions for implicit receiver usage (this.property, this.method())
158-
IrExpression::Ast(ast) => ast_contains_implicit_receiver(ast),
159-
// Check ExpressionRef by looking up the stored expression
160-
IrExpression::ExpressionRef(id) => {
161-
let stored_expr = expressions.get(*id);
162-
ast_contains_implicit_receiver(stored_expr)
163-
}
164-
// Resolved expressions (created by resolveNames phase)
165-
IrExpression::ResolvedCall(rc) => {
166-
expression_contains_context(&rc.receiver, expressions)
167-
|| rc.args.iter().any(|e| expression_contains_context(e, expressions))
168-
}
169-
IrExpression::ResolvedPropertyRead(rp) => {
170-
expression_contains_context(&rp.receiver, expressions)
171-
}
172-
IrExpression::ResolvedBinary(rb) => {
173-
expression_contains_context(&rb.left, expressions)
174-
|| expression_contains_context(&rb.right, expressions)
175-
}
176-
IrExpression::ResolvedKeyedRead(rk) => {
177-
expression_contains_context(&rk.receiver, expressions)
178-
|| expression_contains_context(&rk.key, expressions)
179-
}
180-
IrExpression::ResolvedSafePropertyRead(rsp) => {
181-
expression_contains_context(&rsp.receiver, expressions)
182-
}
183-
IrExpression::SafeTernary(st) => {
184-
expression_contains_context(&st.guard, expressions)
185-
|| expression_contains_context(&st.expr, expressions)
186-
}
187-
IrExpression::SafePropertyRead(sp) => {
188-
expression_contains_context(&sp.receiver, expressions)
189-
}
190-
IrExpression::SafeKeyedRead(sk) => {
191-
expression_contains_context(&sk.receiver, expressions)
192-
|| expression_contains_context(&sk.index, expressions)
193-
}
194-
IrExpression::SafeInvokeFunction(sf) => {
195-
expression_contains_context(&sf.receiver, expressions)
196-
|| sf.args.iter().any(|e| expression_contains_context(e, expressions))
197-
}
198-
IrExpression::PipeBinding(pb) => {
199-
pb.args.iter().any(|e| expression_contains_context(e, expressions))
200-
}
201-
IrExpression::PipeBindingVariadic(pbv) => {
202-
expression_contains_context(&pbv.args, expressions)
203-
}
204-
IrExpression::PureFunction(pf) => {
205-
pf.args.iter().any(|e| expression_contains_context(e, expressions))
206-
}
207-
IrExpression::Interpolation(i) => {
208-
i.expressions.iter().any(|e| expression_contains_context(e, expressions))
209-
}
210-
IrExpression::ResetView(rv) => expression_contains_context(&rv.expr, expressions),
211-
IrExpression::ConditionalCase(cc) => {
212-
cc.expr.as_ref().is_some_and(|e| expression_contains_context(e, expressions))
213-
}
214-
IrExpression::TwoWayBindingSet(tbs) => {
215-
expression_contains_context(&tbs.target, expressions)
216-
|| expression_contains_context(&tbs.value, expressions)
217-
}
218-
IrExpression::StoreLet(sl) => expression_contains_context(&sl.value, expressions),
219-
IrExpression::ConstCollected(cc) => expression_contains_context(&cc.expr, expressions),
220-
IrExpression::RestoreView(rv) => {
221-
if let crate::ir::expression::RestoreViewTarget::Dynamic(e) = &rv.view {
222-
expression_contains_context(e, expressions)
223-
} else {
224-
false
225-
}
226-
}
227-
// Leaf expressions
228-
_ => false,
229-
}
230-
}
231-
232-
/// Check if an Angular AST expression contains any reference to the implicit receiver (this).
233-
/// This includes property reads like `this.foo` and method calls like `this.bar()`.
234-
fn ast_contains_implicit_receiver(ast: &crate::ast::expression::AngularExpression<'_>) -> bool {
235-
use crate::ast::expression::AngularExpression;
236-
237-
match ast {
238-
// Direct implicit receiver reference
239-
AngularExpression::ImplicitReceiver(_) => true,
240-
// Property read - check if it's on implicit receiver or recurse
241-
AngularExpression::PropertyRead(pr) => ast_contains_implicit_receiver(&pr.receiver),
242-
// Safe property read
243-
AngularExpression::SafePropertyRead(pr) => ast_contains_implicit_receiver(&pr.receiver),
244-
// Keyed read
245-
AngularExpression::KeyedRead(kr) => {
246-
ast_contains_implicit_receiver(&kr.receiver) || ast_contains_implicit_receiver(&kr.key)
247-
}
248-
// Safe keyed read
249-
AngularExpression::SafeKeyedRead(kr) => {
250-
ast_contains_implicit_receiver(&kr.receiver) || ast_contains_implicit_receiver(&kr.key)
251-
}
252-
// Function call
253-
AngularExpression::Call(call) => {
254-
ast_contains_implicit_receiver(&call.receiver)
255-
|| call.args.iter().any(ast_contains_implicit_receiver)
256-
}
257-
// Safe call
258-
AngularExpression::SafeCall(call) => {
259-
ast_contains_implicit_receiver(&call.receiver)
260-
|| call.args.iter().any(ast_contains_implicit_receiver)
261-
}
262-
// Binary expression
263-
AngularExpression::Binary(b) => {
264-
ast_contains_implicit_receiver(&b.left) || ast_contains_implicit_receiver(&b.right)
265-
}
266-
// Unary expression
267-
AngularExpression::Unary(u) => ast_contains_implicit_receiver(&u.expr),
268-
// Conditional (ternary)
269-
AngularExpression::Conditional(c) => {
270-
ast_contains_implicit_receiver(&c.condition)
271-
|| ast_contains_implicit_receiver(&c.true_exp)
272-
|| ast_contains_implicit_receiver(&c.false_exp)
273-
}
274-
// Pipe binding
275-
AngularExpression::BindingPipe(p) => {
276-
ast_contains_implicit_receiver(&p.exp)
277-
|| p.args.iter().any(ast_contains_implicit_receiver)
278-
}
279-
// Not expressions
280-
AngularExpression::PrefixNot(n) => ast_contains_implicit_receiver(&n.expression),
281-
AngularExpression::NonNullAssert(n) => ast_contains_implicit_receiver(&n.expression),
282-
// Typeof/void expressions
283-
AngularExpression::TypeofExpression(t) => ast_contains_implicit_receiver(&t.expression),
284-
AngularExpression::VoidExpression(v) => ast_contains_implicit_receiver(&v.expression),
285-
AngularExpression::SpreadElement(spread) => {
286-
ast_contains_implicit_receiver(&spread.expression)
287-
}
288-
// Chain - check all expressions
289-
AngularExpression::Chain(c) => c.expressions.iter().any(ast_contains_implicit_receiver),
290-
// Interpolation - check all expressions
291-
AngularExpression::Interpolation(i) => {
292-
i.expressions.iter().any(ast_contains_implicit_receiver)
293-
}
294-
// Template literals
295-
AngularExpression::TemplateLiteral(t) => {
296-
t.expressions.iter().any(ast_contains_implicit_receiver)
297-
}
298-
AngularExpression::TaggedTemplateLiteral(t) => {
299-
ast_contains_implicit_receiver(&t.tag)
300-
|| t.template.expressions.iter().any(ast_contains_implicit_receiver)
301-
}
302-
// Array literal
303-
AngularExpression::LiteralArray(arr) => {
304-
arr.expressions.iter().any(ast_contains_implicit_receiver)
305-
}
306-
// Map literal
307-
AngularExpression::LiteralMap(map) => map.values.iter().any(ast_contains_implicit_receiver),
308-
// Parenthesized expression
309-
AngularExpression::ParenthesizedExpression(p) => {
310-
ast_contains_implicit_receiver(&p.expression)
311-
}
312-
// Arrow function - check the body
313-
AngularExpression::ArrowFunction(arrow) => ast_contains_implicit_receiver(&arrow.body),
314-
// Literals and other leaf nodes don't contain implicit receiver
315-
AngularExpression::LiteralPrimitive(_)
316-
| AngularExpression::Empty(_)
317-
| AngularExpression::ThisReceiver(_)
318-
| AngularExpression::RegularExpressionLiteral(_) => false,
319-
}
320-
}
321-
322157
/// Check if the track expression is a simple variable read of $index or $item.
323158
fn check_simple_track_variable(
324159
track: &IrExpression<'_>,

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,130 @@ fn test_nested_for_with_outer_scope_track() {
11881188
insta::assert_snapshot!("nested_for_with_outer_scope_track", js);
11891189
}
11901190

1191+
/// Tests that `track prefix() + item.id` generates a regular function (not arrow function).
1192+
/// When a binary expression in track contains a component method call, the generated
1193+
/// track function must use `function` declaration to properly bind `this`.
1194+
#[test]
1195+
fn test_for_track_binary_with_component_method() {
1196+
let js = compile_template_to_js(
1197+
r#"@for (item of items; track prefix() + item.id) { <div>{{item.name}}</div> }"#,
1198+
"TestComponent",
1199+
);
1200+
// Must generate a regular function, not an arrow function, because prefix() needs `this`
1201+
assert!(
1202+
js.contains("function _forTrack"),
1203+
"Track with binary operator containing component method should generate a regular function. Output:\n{js}"
1204+
);
1205+
assert!(
1206+
js.contains("this.prefix()"),
1207+
"Track function should use 'this.prefix()' for component method access. Output:\n{js}"
1208+
);
1209+
// Must NOT be an arrow function (arrow functions don't bind `this`)
1210+
assert!(
1211+
!js.contains("const _forTrack"),
1212+
"Should NOT generate an arrow function (const _forTrack = ...) for track expressions that reference component members. Output:\n{js}"
1213+
);
1214+
insta::assert_snapshot!("for_track_binary_with_component_method", js);
1215+
}
1216+
1217+
/// Tests that nullish coalescing (??) in track with component method generates a regular function.
1218+
/// This is the exact pattern from the original bug report: `track item.prefix ?? defaultPrefix()`
1219+
#[test]
1220+
fn test_for_track_nullish_coalescing_with_component_method() {
1221+
let js = compile_template_to_js(
1222+
r#"@for (item of items; track item.prefix ?? defaultPrefix()) { <div>{{item.name}}</div> }"#,
1223+
"TestComponent",
1224+
);
1225+
assert!(
1226+
js.contains("function _forTrack"),
1227+
"Track with ?? operator containing component method should generate a regular function. Output:\n{js}"
1228+
);
1229+
assert!(
1230+
!js.contains("const _forTrack"),
1231+
"Should NOT generate an arrow function for track with ?? referencing component members. Output:\n{js}"
1232+
);
1233+
insta::assert_snapshot!("for_track_nullish_coalescing_with_component_method", js);
1234+
}
1235+
1236+
/// Tests that ternary in track with component method generates a regular function.
1237+
#[test]
1238+
fn test_for_track_ternary_with_component_method() {
1239+
let js = compile_template_to_js(
1240+
r#"@for (item of items; track useId() ? item.id : item.name) { <div>{{item.name}}</div> }"#,
1241+
"TestComponent",
1242+
);
1243+
assert!(
1244+
js.contains("function _forTrack"),
1245+
"Track with ternary containing component method should generate a regular function. Output:\n{js}"
1246+
);
1247+
assert!(
1248+
!js.contains("const _forTrack"),
1249+
"Should NOT generate an arrow function for track with ternary referencing component members. Output:\n{js}"
1250+
);
1251+
insta::assert_snapshot!("for_track_ternary_with_component_method", js);
1252+
}
1253+
1254+
/// Tests that a complex track expression with multiple component references and binary operators
1255+
/// generates a regular function. Mirrors the original bug: `(tag.queryPrefix ?? queryPrefix()) + '.' + tag.key`
1256+
#[test]
1257+
fn test_for_track_complex_binary_with_nullish_coalescing() {
1258+
let js = compile_template_to_js(
1259+
r#"@for (tag of visibleTags(); track (tag.queryPrefix ?? queryPrefix()) + '.' + tag.key) { <span>{{ tag.key }}</span> }"#,
1260+
"TestComponent",
1261+
);
1262+
assert!(
1263+
js.contains("function _forTrack"),
1264+
"Complex track with ?? and + containing component method should generate a regular function. Output:\n{js}"
1265+
);
1266+
assert!(
1267+
!js.contains("const _forTrack"),
1268+
"Should NOT generate an arrow function. Output:\n{js}"
1269+
);
1270+
assert!(
1271+
js.contains("this.queryPrefix()"),
1272+
"Track function should use 'this.queryPrefix()' for component method access. Output:\n{js}"
1273+
);
1274+
insta::assert_snapshot!("for_track_complex_binary_with_nullish_coalescing", js);
1275+
}
1276+
1277+
/// Tests that a track expression with only item property reads in binary operators
1278+
/// correctly generates an arrow function (no component context needed).
1279+
#[test]
1280+
fn test_for_track_binary_without_component_context() {
1281+
let js = compile_template_to_js(
1282+
r#"@for (item of items; track item.type + ':' + item.id) { <div>{{item.name}}</div> }"#,
1283+
"TestComponent",
1284+
);
1285+
// This should be an arrow function since no component members are referenced
1286+
assert!(
1287+
js.contains("const _forTrack"),
1288+
"Track with binary operator using only item properties should generate an arrow function. Output:\n{js}"
1289+
);
1290+
assert!(
1291+
!js.contains("function _forTrack"),
1292+
"Should NOT generate a regular function when no component members are referenced. Output:\n{js}"
1293+
);
1294+
insta::assert_snapshot!("for_track_binary_without_component_context", js);
1295+
}
1296+
1297+
/// Tests that negation (!) in track with component method generates a regular function.
1298+
#[test]
1299+
fn test_for_track_not_with_component_method() {
1300+
let js = compile_template_to_js(
1301+
r#"@for (item of items; track !isDisabled()) { <div>{{item.name}}</div> }"#,
1302+
"TestComponent",
1303+
);
1304+
assert!(
1305+
js.contains("function _forTrack"),
1306+
"Track with ! operator containing component method should generate a regular function. Output:\n{js}"
1307+
);
1308+
assert!(
1309+
!js.contains("const _forTrack"),
1310+
"Should NOT generate an arrow function. Output:\n{js}"
1311+
);
1312+
insta::assert_snapshot!("for_track_not_with_component_method", js);
1313+
}
1314+
11911315
#[test]
11921316
fn test_if_inside_for() {
11931317
let js = compile_template_to_js(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function _forTrack0($index,$item) {
6+
return (this.prefix() + $item.id);
7+
}
8+
function TestComponent_For_1_Template(rf,ctx) {
9+
if ((rf & 1)) {
10+
i0.ɵɵtext(0," ");
11+
i0.ɵɵelementStart(1,"div");
12+
i0.ɵɵtext(2);
13+
i0.ɵɵelementEnd();
14+
i0.ɵɵtext(3," ");
15+
}
16+
if ((rf & 2)) {
17+
const item_r1 = ctx.$implicit;
18+
i0.ɵɵadvance(2);
19+
i0.ɵɵtextInterpolate(item_r1.name);
20+
}
21+
}
22+
function TestComponent_Template(rf,ctx) {
23+
if ((rf & 1)) { i0.ɵɵrepeaterCreate(0,TestComponent_For_1_Template,4,1,null,null,_forTrack0,
24+
true); }
25+
if ((rf & 2)) { i0.ɵɵrepeater(ctx.items); }
26+
}

0 commit comments

Comments
 (0)