Skip to content

Commit fddd914

Browse files
Brooooooklynclaude
andcommitted
fix: listener handler scope should not merge parent local_definitions in resolve_names
In Angular's resolve_names.ts, listener handler ops are processed as a completely separate lexical scope via recursive processLexicalScope(unit, op.handlerOps, savedView) — no merging from the parent view's scope. OXC incorrectly merged the parent view's update_scope (including local_definitions) into the handler scope. This caused @let declarations (which have local=true) from the update scope to take precedence over the handler's ContextLetReferenceExpr variables. The ContextLetReferenceExpr became unused, was removed by optimizeVariables, and then optimizeStoreLet couldn't find external @let usage — incorrectly removing the storeLet wrapper and causing cumulative pipe varOffset drift. The fix makes handler ops self-contained (matching Angular), since generateVariables already prepends all necessary variables to handler_ops. Fixes 43 ClickUp comparison mismatches (203 → 160, 96.5% → 97.3%). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c14c90b commit fddd914

File tree

5 files changed

+213
-166
lines changed

5 files changed

+213
-166
lines changed

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

Lines changed: 54 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,47 @@ impl<'a> ScopeMaps<'a> {
9898
}
9999
}
100100

101+
/// Build scope maps from listener handler_ops.
102+
///
103+
/// Per Angular's resolve_names.ts (line 86), handler ops are processed as a
104+
/// completely separate lexical scope via recursive `processLexicalScope(unit, op.handlerOps, savedView)`.
105+
/// The generateVariables phase already prepends all necessary variables to handler_ops,
106+
/// so no merging from the parent view's scope is needed. This matches Angular's
107+
/// behavior where each `processLexicalScope` call starts with fresh scope/localDefinitions maps.
108+
fn build_scope_from_handler_ops<'a, 'b>(
109+
ops: impl Iterator<Item = &'b UpdateOp<'a>>,
110+
) -> ScopeMaps<'a>
111+
where
112+
'a: 'b,
113+
{
114+
let mut maps = ScopeMaps::default();
115+
for op in ops {
116+
if let UpdateOp::Variable(var_op) = op {
117+
match var_op.kind {
118+
SemanticVariableKind::Identifier => {
119+
if var_op.local {
120+
if !maps.local_definitions.contains_key(&var_op.name) {
121+
maps.local_definitions.insert(var_op.name.clone(), var_op.xref);
122+
}
123+
} else if !maps.scope.contains_key(&var_op.name) {
124+
maps.scope.insert(var_op.name.clone(), var_op.xref);
125+
}
126+
if !maps.scope.contains_key(&var_op.name) {
127+
maps.scope.insert(var_op.name.clone(), var_op.xref);
128+
}
129+
}
130+
SemanticVariableKind::Alias => {
131+
if !maps.scope.contains_key(&var_op.name) {
132+
maps.scope.insert(var_op.name.clone(), var_op.xref);
133+
}
134+
}
135+
_ => {}
136+
}
137+
}
138+
}
139+
maps
140+
}
141+
101142
/// Build scope maps from update operations (for variables like context reads).
102143
fn build_scope_from_update_ops<'a>(ops: &crate::ir::list::UpdateOpList<'a>) -> ScopeMaps<'a> {
103144
let mut maps = ScopeMaps::default();
@@ -193,58 +234,13 @@ fn process_lexical_scope_create<'a>(
193234
for op in ops.iter_mut() {
194235
match op {
195236
CreateOp::Listener(listener) => {
196-
// Build handler-specific scope that includes Variables from handler_ops.
197-
// This is critical for @for loops where listeners need access to loop variables
198-
// that were prepended to handler_ops by generate_variables phase.
199-
//
200-
// We use "first wins" semantics: the first Variable with a given name takes
201-
// precedence. This matches Angular TypeScript compiler behavior in
202-
// resolve_names.ts (lines 55-58).
203-
//
204-
// handler_ops Variables override update_scope Variables, but among handler_ops
205-
// Variables themselves, the first one wins. This is achieved by:
206-
// 1. First building a scope from handler_ops only (first wins)
207-
// 2. Then merging in update_scope for names not in handler_ops
208-
let mut handler_scope = ScopeMaps::default();
209-
// First pass: add handler_ops Variables (first wins)
210-
for handler_op in listener.handler_ops.iter() {
211-
if let UpdateOp::Variable(var_op) = handler_op {
212-
match var_op.kind {
213-
SemanticVariableKind::Identifier => {
214-
// Handler-local variables take precedence
215-
if var_op.local
216-
&& !handler_scope.local_definitions.contains_key(&var_op.name)
217-
{
218-
handler_scope
219-
.local_definitions
220-
.insert(var_op.name.clone(), var_op.xref);
221-
}
222-
// Only add if not already present (first wins)
223-
if !handler_scope.scope.contains_key(&var_op.name) {
224-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
225-
}
226-
}
227-
SemanticVariableKind::Alias => {
228-
// Only add if not already present (first wins)
229-
if !handler_scope.scope.contains_key(&var_op.name) {
230-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
231-
}
232-
}
233-
_ => {}
234-
}
235-
}
236-
}
237-
// Second pass: merge in update_scope for names not in handler_ops
238-
for (name, xref) in &scope.scope {
239-
if !handler_scope.scope.contains_key(name) {
240-
handler_scope.scope.insert(name.clone(), *xref);
241-
}
242-
}
243-
for (name, xref) in &scope.local_definitions {
244-
if !handler_scope.local_definitions.contains_key(name) {
245-
handler_scope.local_definitions.insert(name.clone(), *xref);
246-
}
247-
}
237+
// Per Angular's resolve_names.ts (line 86):
238+
// processLexicalScope(unit, op.handlerOps, savedView);
239+
// Handler ops are processed as a SEPARATE lexical scope — a fresh
240+
// scope/localDefinitions is built from handler_ops variables only,
241+
// with NO merging from the parent view's scope. The generateVariables
242+
// phase already prepends all necessary variables to handler_ops.
243+
let handler_scope = build_scope_from_handler_ops(listener.handler_ops.iter());
248244

249245
// Process listener handler_ops with the handler scope
250246
for handler_op in listener.handler_ops.iter_mut() {
@@ -276,43 +272,8 @@ fn process_lexical_scope_create<'a>(
276272
}
277273
}
278274
CreateOp::TwoWayListener(listener) => {
279-
// Build handler-specific scope for TwoWayListener
280-
// Uses same "first wins" semantics as Listener (see above)
281-
let mut handler_scope = ScopeMaps::default();
282-
for handler_op in listener.handler_ops.iter() {
283-
if let UpdateOp::Variable(var_op) = handler_op {
284-
match var_op.kind {
285-
SemanticVariableKind::Identifier => {
286-
if var_op.local
287-
&& !handler_scope.local_definitions.contains_key(&var_op.name)
288-
{
289-
handler_scope
290-
.local_definitions
291-
.insert(var_op.name.clone(), var_op.xref);
292-
}
293-
if !handler_scope.scope.contains_key(&var_op.name) {
294-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
295-
}
296-
}
297-
SemanticVariableKind::Alias => {
298-
if !handler_scope.scope.contains_key(&var_op.name) {
299-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
300-
}
301-
}
302-
_ => {}
303-
}
304-
}
305-
}
306-
for (name, xref) in &scope.scope {
307-
if !handler_scope.scope.contains_key(name) {
308-
handler_scope.scope.insert(name.clone(), *xref);
309-
}
310-
}
311-
for (name, xref) in &scope.local_definitions {
312-
if !handler_scope.local_definitions.contains_key(name) {
313-
handler_scope.local_definitions.insert(name.clone(), *xref);
314-
}
315-
}
275+
// Per Angular's resolve_names.ts: handler ops get their own scope
276+
let handler_scope = build_scope_from_handler_ops(listener.handler_ops.iter());
316277

317278
for handler_op in listener.handler_ops.iter_mut() {
318279
transform_expressions_in_update_op(
@@ -330,46 +291,10 @@ fn process_lexical_scope_create<'a>(
330291
VisitorContextFlag::NONE,
331292
);
332293
}
333-
// Note: TwoWayListenerOp has no handler_expression field
334294
}
335295
CreateOp::AnimationListener(listener) => {
336-
// Build handler-specific scope for AnimationListener
337-
// Uses same "first wins" semantics as Listener (see above)
338-
let mut handler_scope = ScopeMaps::default();
339-
for handler_op in listener.handler_ops.iter() {
340-
if let UpdateOp::Variable(var_op) = handler_op {
341-
match var_op.kind {
342-
SemanticVariableKind::Identifier => {
343-
if var_op.local
344-
&& !handler_scope.local_definitions.contains_key(&var_op.name)
345-
{
346-
handler_scope
347-
.local_definitions
348-
.insert(var_op.name.clone(), var_op.xref);
349-
}
350-
if !handler_scope.scope.contains_key(&var_op.name) {
351-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
352-
}
353-
}
354-
SemanticVariableKind::Alias => {
355-
if !handler_scope.scope.contains_key(&var_op.name) {
356-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
357-
}
358-
}
359-
_ => {}
360-
}
361-
}
362-
}
363-
for (name, xref) in &scope.scope {
364-
if !handler_scope.scope.contains_key(name) {
365-
handler_scope.scope.insert(name.clone(), *xref);
366-
}
367-
}
368-
for (name, xref) in &scope.local_definitions {
369-
if !handler_scope.local_definitions.contains_key(name) {
370-
handler_scope.local_definitions.insert(name.clone(), *xref);
371-
}
372-
}
296+
// Per Angular's resolve_names.ts: handler ops get their own scope
297+
let handler_scope = build_scope_from_handler_ops(listener.handler_ops.iter());
373298

374299
for handler_op in listener.handler_ops.iter_mut() {
375300
transform_expressions_in_update_op(
@@ -387,46 +312,10 @@ fn process_lexical_scope_create<'a>(
387312
VisitorContextFlag::NONE,
388313
);
389314
}
390-
// Note: AnimationListenerOp has no handler_expression field
391315
}
392316
CreateOp::Animation(animation) => {
393-
// Build handler-specific scope for Animation
394-
// Uses same "first wins" semantics as Listener (see above)
395-
let mut handler_scope = ScopeMaps::default();
396-
for handler_op in animation.handler_ops.iter() {
397-
if let UpdateOp::Variable(var_op) = handler_op {
398-
match var_op.kind {
399-
SemanticVariableKind::Identifier => {
400-
if var_op.local
401-
&& !handler_scope.local_definitions.contains_key(&var_op.name)
402-
{
403-
handler_scope
404-
.local_definitions
405-
.insert(var_op.name.clone(), var_op.xref);
406-
}
407-
if !handler_scope.scope.contains_key(&var_op.name) {
408-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
409-
}
410-
}
411-
SemanticVariableKind::Alias => {
412-
if !handler_scope.scope.contains_key(&var_op.name) {
413-
handler_scope.scope.insert(var_op.name.clone(), var_op.xref);
414-
}
415-
}
416-
_ => {}
417-
}
418-
}
419-
}
420-
for (name, xref) in &scope.scope {
421-
if !handler_scope.scope.contains_key(name) {
422-
handler_scope.scope.insert(name.clone(), *xref);
423-
}
424-
}
425-
for (name, xref) in &scope.local_definitions {
426-
if !handler_scope.local_definitions.contains_key(name) {
427-
handler_scope.local_definitions.insert(name.clone(), *xref);
428-
}
429-
}
317+
// Per Angular's resolve_names.ts: handler ops get their own scope
318+
let handler_scope = build_scope_from_handler_ops(animation.handler_ops.iter());
430319

431320
for handler_op in animation.handler_ops.iter_mut() {
432321
transform_expressions_in_update_op(
@@ -444,7 +333,6 @@ fn process_lexical_scope_create<'a>(
444333
VisitorContextFlag::NONE,
445334
);
446335
}
447-
// Note: AnimationOp has no handler_expression field
448336
}
449337
_ => {
450338
transform_expressions_in_create_op(

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,88 @@ fn test_let_inside_for_if_with_component_method_call() {
576576
insta::assert_snapshot!("let_inside_for_if_with_component_method_call", js);
577577
}
578578

579+
// ============================================================================
580+
// @let with pipe in child view Tests
581+
// ============================================================================
582+
583+
#[test]
584+
fn test_let_with_pipe_used_in_child_view() {
585+
// @let with pipe used in a child view (@if block) should keep BOTH declareLet and storeLet.
586+
//
587+
// When a @let wraps a pipe and is referenced from a child view:
588+
// - declareLet is needed because pipes use DI which requires the TNode
589+
// - storeLet is needed because the @let is accessed from another view via readContextLet
590+
//
591+
// Without storeLet, the pipe's varOffset would be wrong because storeLet contributes
592+
// 1 var to the var counting, and removing it shifts all subsequent varOffsets.
593+
//
594+
// Expected Angular output:
595+
// i0.ɵɵstoreLet(i0.ɵɵpipeBind1(1, varOffset, ctx.name));
596+
//
597+
// Bug output (missing storeLet):
598+
// i0.ɵɵpipeBind1(1, varOffset, ctx.name);
599+
let js = compile_template_to_js(
600+
r"@let value = name | uppercase; @if (true) { {{value}} }",
601+
"TestComponent",
602+
);
603+
// storeLet must wrap pipeBind because @let is used externally (in child @if view)
604+
assert!(
605+
js.contains("ɵɵstoreLet(i0.ɵɵpipeBind1("),
606+
"storeLet should wrap pipeBind1 when @let with pipe is used in child view. Output:\n{js}"
607+
);
608+
// declareLet must be present (pipes need TNode for DI)
609+
assert!(
610+
js.contains("ɵɵdeclareLet("),
611+
"declareLet should be present when @let contains a pipe. Output:\n{js}"
612+
);
613+
// readContextLet must be present in the child view
614+
assert!(
615+
js.contains("ɵɵreadContextLet("),
616+
"readContextLet should be present in child view. Output:\n{js}"
617+
);
618+
insta::assert_snapshot!("let_with_pipe_used_in_child_view", js);
619+
}
620+
621+
#[test]
622+
fn test_let_with_pipe_used_in_listener() {
623+
// @let with pipe used in an event listener in the same view should keep storeLet.
624+
//
625+
// Event listeners are callbacks (isCallback=true), so @let declarations
626+
// in the same view generate ContextLetReferenceExpr in the listener's handler ops.
627+
// This means the @let is "used externally" and storeLet must be preserved.
628+
let js = compile_template_to_js(
629+
r#"@let value = name | uppercase; <button (click)="onClick(value)">Click</button>"#,
630+
"TestComponent",
631+
);
632+
// storeLet must wrap pipeBind because @let is used externally (in listener callback)
633+
assert!(
634+
js.contains("ɵɵstoreLet(i0.ɵɵpipeBind1("),
635+
"storeLet should wrap pipeBind1 when @let with pipe is used in listener. Output:\n{js}"
636+
);
637+
insta::assert_snapshot!("let_with_pipe_used_in_listener", js);
638+
}
639+
640+
#[test]
641+
fn test_let_with_pipe_multiple_in_child_view_varoffset() {
642+
// Multiple @let declarations with pipes used in a child view.
643+
// Each storeLet contributes 1 var, so removing them would cause cumulative varOffset drift.
644+
//
645+
// This reproduces the ClickUp AdvancedTabComponent pattern where multiple @let
646+
// declarations with pipes have their storeLet wrappers incorrectly removed,
647+
// causing the second pipe's varOffset to drift by +1 for each missing storeLet.
648+
let js = compile_template_to_js(
649+
r"@let a = x | uppercase; @let b = y | lowercase; @if (true) { {{a}} {{b}} }",
650+
"TestComponent",
651+
);
652+
// Both @let values should have storeLet wrappers
653+
let store_let_count = js.matches("ɵɵstoreLet(").count();
654+
assert!(
655+
store_let_count >= 2,
656+
"Expected at least 2 storeLet calls for 2 @let declarations with pipes used in child view, got {store_let_count}. Output:\n{js}"
657+
);
658+
insta::assert_snapshot!("let_with_pipe_multiple_in_child_view_varoffset", js);
659+
}
660+
579661
// ============================================================================
580662
// ng-content Tests
581663
// ============================================================================
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/integration_test.rs
3+
expression: js
4+
---
5+
function TestComponent_Conditional_6_Template(rf,ctx) {
6+
if ((rf & 1)) { i0.ɵɵtext(0); }
7+
if ((rf & 2)) {
8+
i0.ɵɵnextContext();
9+
const a_r1 = i0.ɵɵreadContextLet(0);
10+
const b_r2 = i0.ɵɵreadContextLet(3);
11+
i0.ɵɵtextInterpolate2(" ",a_r1," ",b_r2," ");
12+
}
13+
}
14+
function TestComponent_Template(rf,ctx) {
15+
if ((rf & 1)) {
16+
i0.ɵɵdeclareLet(0);
17+
i0.ɵɵpipe(1,"uppercase");
18+
i0.ɵɵtext(2," ");
19+
i0.ɵɵdeclareLet(3);
20+
i0.ɵɵpipe(4,"lowercase");
21+
i0.ɵɵtext(5," ");
22+
i0.ɵɵconditionalCreate(6,TestComponent_Conditional_6_Template,1,2);
23+
}
24+
if ((rf & 2)) {
25+
i0.ɵɵstoreLet(i0.ɵɵpipeBind1(1,1,ctx.x));
26+
i0.ɵɵadvance(3);
27+
i0.ɵɵstoreLet(i0.ɵɵpipeBind1(4,4,ctx.y));
28+
i0.ɵɵadvance(3);
29+
i0.ɵɵconditional((true? 6: -1));
30+
}
31+
}

0 commit comments

Comments
 (0)