Skip to content

Commit 8100484

Browse files
Brooooooklynclaude
andauthored
fix: listener handler_expression should generate tmp variable declarations in handler scope (#8)
* fix: listener handler_expression should generate tmp variable declarations in handler scope handler_expression was being processed without the IN_CHILD_OPERATION flag, causing temporary variables to be scoped to the parent create block instead of the listener function. This meant `let tmp_N_0;` declarations were missing inside listener functions that use safe navigation on function call receivers (e.g., `getPopover()?.close()`). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: process handler_expression after handler_ops to avoid duplicate tmp declarations handler_expression was being processed inside the per-op loop, causing it to be visited/transformed once per handler_op. When handler_ops had multiple entries (e.g., restoreView + nextContext), this produced spurious declarations (let tmp_0_0; let tmp_1_0;) and wrong variable names. Now handler_expression is processed once after the loop with op_count set to handler_ops.len(), matching Angular TS where the return expression is the last entry in handlerOps. For a listener with 2 handler_ops, this correctly generates tmp_2_0 instead of tmp_1_0. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bf443cb commit 8100484

6 files changed

Lines changed: 176 additions & 9 deletions

crates/oxc_angular_compiler/src/ir/expression.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,12 +2092,13 @@ pub fn transform_expressions_in_create_op<'a, F>(
20922092
transform_expressions_in_expression(&mut op.initializer, transform, flags);
20932093
}
20942094
CreateOp::Listener(op) => {
2095-
// Process handler expression if present
2095+
// Process handler ops and handler_expression as child operations.
2096+
// handler_expression is the return expression of the listener function and
2097+
// must be treated as part of the handler scope (not the parent scope).
2098+
let child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION);
20962099
if let Some(handler_expr) = &mut op.handler_expression {
2097-
transform_expressions_in_expression(handler_expr, transform, flags);
2100+
transform_expressions_in_expression(handler_expr, transform, child_flags);
20982101
}
2099-
// Process handler ops in the listener
2100-
let child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION);
21012102
for handler_op in op.handler_ops.iter_mut() {
21022103
transform_expressions_in_update_op(handler_op, transform, child_flags);
21032104
}
@@ -2279,10 +2280,10 @@ pub fn visit_expressions_in_create_op<'a, F>(
22792280
visit_expressions_in_expression(&op.initializer, visitor, flags);
22802281
}
22812282
CreateOp::Listener(op) => {
2283+
let child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION);
22822284
if let Some(handler_expr) = &op.handler_expression {
2283-
visit_expressions_in_expression(handler_expr, visitor, flags);
2285+
visit_expressions_in_expression(handler_expr, visitor, child_flags);
22842286
}
2285-
let child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION);
22862287
for handler_op in op.handler_ops.iter() {
22872288
visit_expressions_in_update_op(handler_op, visitor, child_flags);
22882289
}

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

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ use rustc_hash::{FxHashMap, FxHashSet};
3939

4040
use crate::ir::expression::{
4141
IrExpression, VisitorContextFlag, transform_expressions_in_create_op,
42-
transform_expressions_in_update_op, visit_expressions_in_create_op,
42+
transform_expressions_in_expression, transform_expressions_in_update_op,
43+
visit_expressions_in_create_op, visit_expressions_in_expression,
4344
visit_expressions_in_update_op,
4445
};
4546
use crate::ir::list::{CreateOpList, UpdateOpList};
@@ -132,8 +133,16 @@ fn generate_temporaries_for_create<'a>(
132133
// for nested ops lists and the results are prepended to those lists.
133134
match op {
134135
CreateOp::Listener(listener) => {
135-
let mut handler_stmts =
136-
generate_temporaries_for_handler_ops(&mut listener.handler_ops, allocator);
136+
// Process handler_ops and handler_expression together.
137+
// In Angular TS, the handler_expression is part of handlerOps.
138+
// In our IR, it's a separate field, so we must include it in
139+
// temporary variable processing to generate the correct `let tmp_N_0;`
140+
// declarations inside the listener function.
141+
let mut handler_stmts = generate_temporaries_for_handler_ops_with_expression(
142+
&mut listener.handler_ops,
143+
&mut listener.handler_expression,
144+
allocator,
145+
);
137146
prepend_update_ops(&mut listener.handler_ops, &mut handler_stmts, allocator);
138147
}
139148
CreateOp::AnimationListener(listener) => {
@@ -219,6 +228,74 @@ fn generate_temporaries_for_handler_ops<'a>(
219228
generated_statements
220229
}
221230

231+
/// Generates temporary variable declarations for handler_ops AND handler_expression together.
232+
///
233+
/// In Angular TS, the handler_expression is part of handlerOps (there is no separate field).
234+
/// In our IR, handler_expression is a separate field on ListenerOp. We must include it in
235+
/// temporary variable processing so that `let tmp_N_0;` declarations are generated inside
236+
/// the listener function scope rather than the parent create scope.
237+
///
238+
/// The handler_ops are processed first (same as `generate_temporaries_for_handler_ops`),
239+
/// then handler_expression is processed once AFTER the loop with op_count equal to
240+
/// handler_ops.len(). This matches Angular TS where the return expression is the last
241+
/// entry in handlerOps at index N.
242+
fn generate_temporaries_for_handler_ops_with_expression<'a>(
243+
ops: &mut [UpdateOp<'a>],
244+
handler_expression: &mut Option<oxc_allocator::Box<'a, IrExpression<'a>>>,
245+
allocator: &'a Allocator,
246+
) -> Vec<UpdateOp<'a>> {
247+
// First, process handler_ops exactly like generate_temporaries_for_handler_ops
248+
let mut generated_statements = generate_temporaries_for_handler_ops(ops, allocator);
249+
250+
// Then process handler_expression once, with op_count = ops.len().
251+
// In Angular TS, the return expression is the last entry in handlerOps,
252+
// so its op index equals the number of preceding ops.
253+
if let Some(handler_expr) = handler_expression.as_mut() {
254+
let op_count = ops.len();
255+
256+
// Pass 1: Count reads per xref to determine final reads
257+
let read_counts = RefCell::new(FxHashMap::<XrefId, usize>::default());
258+
visit_expressions_in_expression(
259+
handler_expr,
260+
&|expr, flags| {
261+
if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) {
262+
return;
263+
}
264+
if let IrExpression::ReadTemporary(read) = expr {
265+
*read_counts.borrow_mut().entry(read.xref).or_insert(0) += 1;
266+
}
267+
},
268+
VisitorContextFlag::NONE,
269+
);
270+
let total_reads = read_counts.into_inner();
271+
272+
// Pass 2: Assign names with reuse when final read is encountered
273+
let tracker = RefCell::new(TempVarTracker::new(op_count, total_reads));
274+
transform_expressions_in_expression(
275+
handler_expr,
276+
&|expr, flags| {
277+
if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) {
278+
return;
279+
}
280+
assign_temp_names(expr, &tracker, allocator);
281+
},
282+
VisitorContextFlag::NONE,
283+
);
284+
285+
// Collect unique names and create declarations
286+
let defs = tracker.into_inner().defs;
287+
for name in collect_unique_names(&defs) {
288+
let stmt = create_declare_var_statement(allocator, &name);
289+
generated_statements.push(UpdateOp::Statement(StatementOp {
290+
base: UpdateOpBase::default(),
291+
statement: stmt,
292+
}));
293+
}
294+
}
295+
296+
generated_statements
297+
}
298+
222299
/// Prepends generated statement ops to a handler_ops Vec.
223300
/// Since handler_ops is a Vec (not OpList), we need to handle prepending manually.
224301
fn prepend_update_ops<'a>(

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,41 @@ fn test_safe_call() {
10401040
insta::assert_snapshot!("safe_call", js);
10411041
}
10421042

1043+
#[test]
1044+
fn test_safe_call_in_listener() {
1045+
// When a listener handler contains `fn()?.method()`, Angular generates a temporary variable
1046+
// because `fn()` is a function call that shouldn't be evaluated twice.
1047+
// Expected output should contain `let tmp_N_0;` declaration inside the listener function.
1048+
let js = compile_template_to_js(
1049+
r#"<button (click)="getPopover()?.close()">Close</button>"#,
1050+
"TestComponent",
1051+
);
1052+
insta::assert_snapshot!("safe_call_in_listener", js);
1053+
}
1054+
1055+
#[test]
1056+
fn test_safe_property_read_with_call_receiver_in_listener() {
1057+
// Pattern: `fn()?.prop` in listener — receiver is a call, needs tmp variable
1058+
let js = compile_template_to_js(
1059+
r#"<button (click)="getDialog()?.visible">Toggle</button>"#,
1060+
"TestComponent",
1061+
);
1062+
insta::assert_snapshot!("safe_property_read_with_call_receiver_in_listener", js);
1063+
}
1064+
1065+
#[test]
1066+
fn test_safe_call_in_listener_inside_conditional() {
1067+
// When a listener is inside an embedded view (e.g., @if), handler_ops contains
1068+
// restoreView and nextContext statements. The handler_expression (return value)
1069+
// must be processed AFTER those ops, not once per op, to get the correct
1070+
// tmp variable name (tmp_2_0, matching the op index after restoreView and nextContext).
1071+
let js = compile_template_to_js(
1072+
r#"@if (show) { <button (click)="getPopover()?.close()">Close</button> }"#,
1073+
"TestComponent",
1074+
);
1075+
insta::assert_snapshot!("safe_call_in_listener_inside_conditional", js);
1076+
}
1077+
10431078
// ============================================================================
10441079
// Event Modifier Tests
10451080
// ============================================================================
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵelementStart(0,"button",0);
8+
i0.ɵɵlistener("click",function TestComponent_Template_button_click_0_listener() {
9+
let tmp_0_0;
10+
return (((tmp_0_0 = ctx.getPopover()) == null)? null: tmp_0_0.close());
11+
});
12+
i0.ɵɵtext(1,"Close");
13+
i0.ɵɵelementEnd();
14+
}
15+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_Conditional_0_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
const _r1 = i0.ɵɵgetCurrentView();
8+
i0.ɵɵtext(0," ");
9+
i0.ɵɵelementStart(1,"button",0);
10+
i0.ɵɵlistener("click",function TestComponent_Conditional_0_Template_button_click_1_listener() {
11+
let tmp_2_0;
12+
i0.ɵɵrestoreView(_r1);
13+
const ctx_r1 = i0.ɵɵnextContext();
14+
return i0.ɵɵresetView((((tmp_2_0 = ctx_r1.getPopover()) == null)? null: tmp_2_0.close()));
15+
});
16+
i0.ɵɵtext(2,"Close");
17+
i0.ɵɵelementEnd();
18+
i0.ɵɵtext(3," ");
19+
}
20+
}
21+
function TestComponent_Template(rf,ctx) {
22+
if ((rf & 1)) { i0.ɵɵconditionalCreate(0,TestComponent_Conditional_0_Template,4,0); }
23+
if ((rf & 2)) { i0.ɵɵconditional((ctx.show? 0: -1)); }
24+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_Template(rf,ctx) {
6+
if ((rf & 1)) {
7+
i0.ɵɵelementStart(0,"button",0);
8+
i0.ɵɵlistener("click",function TestComponent_Template_button_click_0_listener() {
9+
let tmp_0_0;
10+
return (((tmp_0_0 = ctx.getDialog()) == null)? null: tmp_0_0.visible);
11+
});
12+
i0.ɵɵtext(1,"Toggle");
13+
i0.ɵɵelementEnd();
14+
}
15+
}

0 commit comments

Comments
 (0)