Skip to content

Commit 117a8d3

Browse files
Brooooooklynclaude
andauthored
fix(angular): enforce root-view guard in @for track method-call optimization (#38)
Angular's `isTrackByFunctionCall` (track_fn_optimization.ts:96-100) requires `receiver.receiver.view === rootView`, rejecting non-root-view contexts from the optimized path. The Rust port ignored `root_xref` and accepted any Context receiver, which could mis-optimize nested-view track calls. - Check `ctx.view == root_xref` in the ResolvedCall path - Remove the AST fallback path entirely (dead code after resolve_names phase, and ImplicitReceiver lacks a view field to verify the guard) - Add unit tests that directly verify the root-view guard - Add integration tests for root-view vs nested-view track method calls Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8435daf commit 117a8d3

File tree

5 files changed

+221
-52
lines changed

5 files changed

+221
-52
lines changed

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

Lines changed: 84 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ fn optimize_track_expression<'a>(
6969
// Check for method call pattern: this.fn($index) or this.fn($index, $item)
7070
// These can be passed directly to the repeater runtime.
7171
if let Some((method_name, is_root_context)) =
72-
check_track_by_function_call(&rep.track, root_xref, expressions)
72+
check_track_by_function_call(&rep.track, root_xref)
7373
{
7474
rep.uses_component_instance = true;
7575
if is_root_context && view_xref == root_xref {
@@ -381,11 +381,8 @@ fn check_ast_for_simple_track_variable(
381381
/// Returns the method name (as String) and whether the context is the root view.
382382
fn check_track_by_function_call(
383383
track: &IrExpression<'_>,
384-
_root_xref: XrefId,
385-
expressions: &crate::pipeline::expression_store::ExpressionStore<'_>,
384+
root_xref: XrefId,
386385
) -> Option<(String, bool)> {
387-
use crate::ast::expression::AngularExpression;
388-
389386
// Handle ResolvedCall expressions (created by resolveNames phase)
390387
if let IrExpression::ResolvedCall(rc) = track {
391388
// Must have 1 or 2 arguments
@@ -411,60 +408,25 @@ fn check_track_by_function_call(
411408
return None;
412409
}
413410

414-
// Check receiver: must be a ResolvedPropertyRead on Context
411+
// Check receiver: must be a ResolvedPropertyRead on Context whose view is the root view.
412+
// Angular's isTrackByFunctionCall (track_fn_optimization.ts:96-100) requires
413+
// receiver.receiver.view === rootView, rejecting non-root-view contexts.
415414
if let IrExpression::ResolvedPropertyRead(rp) = rc.receiver.as_ref() {
416-
if matches!(rp.receiver.as_ref(), IrExpression::Context(_)) {
417-
return Some((rp.name.to_string(), true));
415+
if let IrExpression::Context(ctx) = rp.receiver.as_ref() {
416+
if ctx.view == root_xref {
417+
return Some((rp.name.to_string(), true));
418+
}
418419
}
419420
}
420421

421422
return None;
422423
}
423424

424-
// Get the AST expression, handling both direct Ast and ExpressionRef
425-
let ast = match track {
426-
IrExpression::Ast(ast) => ast.as_ref(),
427-
IrExpression::ExpressionRef(id) => expressions.get(*id),
428-
_ => return None,
429-
};
430-
431-
// Check for Call expression
432-
if let AngularExpression::Call(call) = ast {
433-
// Must have 1 or 2 arguments
434-
if call.args.is_empty() || call.args.len() > 2 {
435-
return None;
436-
}
437-
438-
// Check arguments: first must be $index, second (if present) must be $item
439-
let first_is_index = matches!(&call.args[0], AngularExpression::PropertyRead(pr)
440-
if matches!(&pr.receiver, AngularExpression::ImplicitReceiver(_))
441-
&& pr.name.as_str() == "$index");
442-
443-
if !first_is_index {
444-
return None;
445-
}
446-
447-
if call.args.len() == 2 {
448-
let second_is_item = matches!(&call.args[1], AngularExpression::PropertyRead(pr)
449-
if matches!(&pr.receiver, AngularExpression::ImplicitReceiver(_))
450-
&& pr.name.as_str() == "$item");
451-
if !second_is_item {
452-
return None;
453-
}
454-
}
455-
456-
// Check receiver: must be a property read on the component context
457-
// Pattern: receiver.methodName where receiver is context (this)
458-
if let AngularExpression::PropertyRead(method_read) = &call.receiver {
459-
// The receiver of the property read should be the implicit receiver (this/context)
460-
if matches!(&method_read.receiver, AngularExpression::ImplicitReceiver(_)) {
461-
// This is a method call on the implicit receiver: this.methodName($index, ...)
462-
// In Angular, this is a call on the component context
463-
return Some((method_read.name.to_string(), true));
464-
}
465-
}
466-
}
467-
425+
// AST fallback path: After resolve_names (phase 31), track expressions should already
426+
// be ResolvedCall/ResolvedPropertyRead with Context receivers. If we reach here with
427+
// raw AST expressions, ImplicitReceiver lacks a view field so we cannot verify the
428+
// root-view guard that Angular's isTrackByFunctionCall requires. Reject optimization
429+
// to avoid mis-optimizing nested-view track calls.
468430
None
469431
}
470432

@@ -497,3 +459,73 @@ fn is_item_variable(expr: &IrExpression<'_>) -> bool {
497459
_ => false,
498460
}
499461
}
462+
463+
#[cfg(test)]
464+
mod tests {
465+
use super::*;
466+
use crate::ir::expression::{ContextExpr, ResolvedCallExpr, ResolvedPropertyReadExpr};
467+
use crate::ir::ops::XrefId;
468+
use crate::output::ast::ReadVarExpr;
469+
use oxc_allocator::Allocator;
470+
471+
/// Build a ResolvedCall IR expression representing `ctx.methodName($index)`,
472+
/// where the Context has the given `context_view`.
473+
fn make_track_method_call<'a>(
474+
alloc: &'a Allocator,
475+
method_name: &'a str,
476+
context_view: XrefId,
477+
) -> IrExpression<'a> {
478+
let ctx = IrExpression::Context(oxc_allocator::Box::new_in(
479+
ContextExpr { view: context_view, source_span: None },
480+
alloc,
481+
));
482+
let prop_read = IrExpression::ResolvedPropertyRead(oxc_allocator::Box::new_in(
483+
ResolvedPropertyReadExpr {
484+
receiver: oxc_allocator::Box::new_in(ctx, alloc),
485+
name: Atom::from(method_name),
486+
source_span: None,
487+
},
488+
alloc,
489+
));
490+
let index_arg = IrExpression::OutputExpr(oxc_allocator::Box::new_in(
491+
OutputExpression::ReadVar(oxc_allocator::Box::new_in(
492+
ReadVarExpr { name: Atom::from("$index"), source_span: None },
493+
alloc,
494+
)),
495+
alloc,
496+
));
497+
let mut args = oxc_allocator::Vec::new_in(alloc);
498+
args.push(index_arg);
499+
IrExpression::ResolvedCall(oxc_allocator::Box::new_in(
500+
ResolvedCallExpr {
501+
receiver: oxc_allocator::Box::new_in(prop_read, alloc),
502+
args,
503+
source_span: None,
504+
},
505+
alloc,
506+
))
507+
}
508+
509+
#[test]
510+
fn test_root_view_context_is_accepted() {
511+
let alloc = Allocator::default();
512+
let root_xref = XrefId(0);
513+
let track = make_track_method_call(&alloc, "trackByFn", root_xref);
514+
515+
let result = check_track_by_function_call(&track, root_xref);
516+
assert_eq!(result, Some(("trackByFn".to_string(), true)));
517+
}
518+
519+
#[test]
520+
fn test_non_root_view_context_is_rejected() {
521+
let alloc = Allocator::default();
522+
let root_xref = XrefId(0);
523+
let non_root_xref = XrefId(5);
524+
let track = make_track_method_call(&alloc, "trackByFn", non_root_xref);
525+
526+
// Before the fix, this would return Some — incorrectly accepting a
527+
// non-root context. After the fix, it returns None.
528+
let result = check_track_by_function_call(&track, root_xref);
529+
assert_eq!(result, None, "non-root context should NOT be optimized");
530+
}
531+
}

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,63 @@ fn test_for_track_bare_method_reference() {
10881088
insta::assert_snapshot!("for_track_bare_method_reference", js);
10891089
}
10901090

1091+
/// Tests that `track trackByFn($index)` inside a nested view (e.g. @if) uses
1092+
/// `componentInstance().trackByFn` instead of `ctx.trackByFn`.
1093+
///
1094+
/// Angular's `isTrackByFunctionCall` (track_fn_optimization.ts:84-115) only optimizes
1095+
/// when the context receiver's view is the root view. The main optimization then checks
1096+
/// `receiver.receiver.view === unit.xref` to decide between `ctx.method` (root view)
1097+
/// and `componentInstance().method` (non-root view).
1098+
///
1099+
/// When the @for is inside @if, the repeater is in a non-root view, so Angular uses
1100+
/// the `componentInstance().method` path.
1101+
#[test]
1102+
fn test_for_track_method_call_in_nested_view() {
1103+
// @for inside @if: repeater is in a non-root view
1104+
let js = compile_template_to_js(
1105+
r"@if (showItems) { @for (item of items; track trackByFn($index)) { <div>{{item.name}}</div> } }",
1106+
"TestComponent",
1107+
);
1108+
// In a nested view, Angular uses componentInstance().trackByFn
1109+
assert!(
1110+
js.contains("componentInstance"),
1111+
"Track method call in nested view should use componentInstance().trackByFn. Output:\n{js}"
1112+
);
1113+
insta::assert_snapshot!("for_track_method_call_in_nested_view", js);
1114+
}
1115+
1116+
/// Tests that `track trackByFn($index)` in the root view uses `ctx.trackByFn`.
1117+
#[test]
1118+
fn test_for_track_method_call_in_root_view() {
1119+
// @for directly in root view
1120+
let js = compile_template_to_js(
1121+
r"@for (item of items; track trackByFn($index)) { <div>{{item.name}}</div> }",
1122+
"TestComponent",
1123+
);
1124+
// In the root view, Angular uses ctx.trackByFn directly
1125+
assert!(
1126+
js.contains("ctx.trackByFn"),
1127+
"Track method call in root view should use ctx.trackByFn. Output:\n{js}"
1128+
);
1129+
insta::assert_snapshot!("for_track_method_call_in_root_view", js);
1130+
}
1131+
1132+
/// Tests that `track trackByFn($index, item)` in the root view uses `ctx.trackByFn`.
1133+
/// Note: The loop variable name (e.g. `item`) is used, not the literal `$item`.
1134+
/// `generateTrackVariables` converts `item` → `$item` ReadVarExpr, which is then optimizable.
1135+
#[test]
1136+
fn test_for_track_method_call_with_both_args() {
1137+
let js = compile_template_to_js(
1138+
r"@for (item of items; track trackByFn($index, item)) { <div>{{item.name}}</div> }",
1139+
"TestComponent",
1140+
);
1141+
assert!(
1142+
js.contains("ctx.trackByFn"),
1143+
"Track method call with ($index, item) in root view should use ctx.trackByFn. Output:\n{js}"
1144+
);
1145+
insta::assert_snapshot!("for_track_method_call_with_both_args", js);
1146+
}
1147+
10911148
#[test]
10921149
fn test_nested_for_loops() {
10931150
let js = compile_template_to_js(
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_Conditional_0_For_2_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵtext(0," ");
8+
i0.ɵɵelementStart(1,"div");
9+
i0.ɵɵtext(2);
10+
i0.ɵɵelementEnd();
11+
i0.ɵɵtext(3," ");
12+
}
13+
if ((rf & 2)) {
14+
const item_r1 = ctx.$implicit;
15+
i0.ɵɵadvance(2);
16+
i0.ɵɵtextInterpolate(item_r1.name);
17+
}
18+
}
19+
function TestComponent_Conditional_0_Template(rf,ctx) {
20+
if ((rf & 1)) {
21+
i0.ɵɵtext(0," ");
22+
i0.ɵɵrepeaterCreate(1,TestComponent_Conditional_0_For_2_Template,4,1,null,null,
23+
i0.ɵɵcomponentInstance().trackByFn,true);
24+
}
25+
if ((rf & 2)) {
26+
const ctx_r1 = i0.ɵɵnextContext();
27+
i0.ɵɵadvance();
28+
i0.ɵɵrepeater(ctx_r1.items);
29+
}
30+
}
31+
function TestComponent_Template(rf,ctx) {
32+
if ((rf & 1)) { i0.ɵɵconditionalCreate(0,TestComponent_Conditional_0_Template,3,0); }
33+
if ((rf & 2)) { i0.ɵɵconditional((ctx.showItems? 0: -1)); }
34+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_For_1_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵtext(0," ");
8+
i0.ɵɵelementStart(1,"div");
9+
i0.ɵɵtext(2);
10+
i0.ɵɵelementEnd();
11+
i0.ɵɵtext(3," ");
12+
}
13+
if ((rf & 2)) {
14+
const item_r1 = ctx.$implicit;
15+
i0.ɵɵadvance(2);
16+
i0.ɵɵtextInterpolate(item_r1.name);
17+
}
18+
}
19+
function TestComponent_Template(rf,ctx) {
20+
if ((rf & 1)) { i0.ɵɵrepeaterCreate(0,TestComponent_For_1_Template,4,1,null,null,ctx.trackByFn,
21+
true); }
22+
if ((rf & 2)) { i0.ɵɵrepeater(ctx.items); }
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_For_1_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵtext(0," ");
8+
i0.ɵɵelementStart(1,"div");
9+
i0.ɵɵtext(2);
10+
i0.ɵɵelementEnd();
11+
i0.ɵɵtext(3," ");
12+
}
13+
if ((rf & 2)) {
14+
const item_r1 = ctx.$implicit;
15+
i0.ɵɵadvance(2);
16+
i0.ɵɵtextInterpolate(item_r1.name);
17+
}
18+
}
19+
function TestComponent_Template(rf,ctx) {
20+
if ((rf & 1)) { i0.ɵɵrepeaterCreate(0,TestComponent_For_1_Template,4,1,null,null,ctx.trackByFn,
21+
true); }
22+
if ((rf & 2)) { i0.ɵɵrepeater(ctx.items); }
23+
}

0 commit comments

Comments
 (0)