Skip to content

fix(compiler): resolve template locals inside $safeNavigationMigration in listeners and arrow functions - #587

Merged
Brooooooklyn merged 3 commits into
voidzero-dev:mainfrom
edbzn:fix/safe-navigation-migration-listener
Oct 20, 2026
Merged

Brooooooklyn merged 3 commits into
voidzero-dev:mainfrom
edbzn:fix/safe-navigation-migration-listener

Conversation

@edbzn

@edbzn edbzn commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #585. Inside an event listener, $safeNavigationMigration(expr) read the template locals in expr (@for items, @if … as aliases, let- variables, #refs, @lets) from ctx instead of from the local restored from the view. So the argument was always undefined, and for example (click)="pick($safeNavigationMigration(option?.value))" passed null. Only the listener's returned (last) expression was affected. Earlier statements were already correct.

The same gap also broke arrow functions in bindings, for example [title]="fmt(() => $safeNavigationMigration(option?.value))". The hoisted arrow factory read a variable that exists only inside the template function, so a call threw a ReferenceError. Plain property bindings were not affected.

Cause

resolve_names resolves most expressions through the generic visitor (transform_expressions_in_*), which goes into every IR node. Two kinds of roots call resolve_expression directly instead:

  • a listener's handler_expression (the returned expression)
  • hoisted arrow-function bodies (template and host)

The explicit match in resolve_expression had no arm for IrExpression::SafeNavigationMigration, so the wrapped subtree hit _ => {}:

  • Listener: the names stayed LexicalReads, and the unused restoreView(...).$implicit variable was dropped. Reify then emitted the leftover reads as ctx.<name>, where ctx is the parameter of the enclosing template function.
  • Hoisted arrow body: the later update pass resolved the names against the template function's scope. So the module-level factory read option_r1, which is not in its scope.

The fix adds the missing arm. It goes into the wrapped expression, like the Parenthesized arm does. This matches upstream: resolveNames walks these expressions with transformExpressionsInOp, and SafeNavigationMigrationExpr passes the transform on to its argument.

While the wrapper exists (from removeSafeNavigationMigration to expandSafeReads), the only other hand-written walker is the $event scan in resolve_dollar_event, and it already goes into the wrapper. expandSafeReads removes the wrapper before the later phases run.

Listener, @for (option of options; track $index) { <button (click)="pick($safeNavigationMigration(option?.value))"> }:

 i0.ɵɵlistener("click",function TestComponent_For_1_Template_button_click_1_listener() {
-  i0.ɵɵrestoreView(_r1);
-  const ctx_r1 = i0.ɵɵnextContext();
-  return i0.ɵɵresetView(ctx_r1.pick(((ctx.option == null)? null: ctx.option.value)));
+  const option_r2 = i0.ɵɵrestoreView(_r1).$implicit;
+  const ctx_r2 = i0.ɵɵnextContext();
+  return i0.ɵɵresetView(ctx_r2.pick(((option_r2 == null)? null: option_r2.value)));
 });

Arrow function in a binding, @for (option of options; track $index) { <div [title]="fmt(() => $safeNavigationMigration(option?.value))"> }:

-const arrowFn0 = (ctx,view) =>() =>((option_r1 == null)? null: option_r1.value);
+const arrowFn0 = (ctx,view) =>() =>{
+  const option_r1 = i0.ɵɵrestoreView(view).$implicit;
+  return i0.ɵɵresetView(((option_r1 == null)? null: option_r1.value));
+};

Why there was no compile error: for the listener case, the check in resolve_names (verify_no_lexical_reads_remain) did push an error diagnostic. But compile_component_full drops job diagnostics when compilation succeeds, so transformAngularFile returned no error. This PR does not change that. The check does not catch the arrow case anyway, because there no LexicalRead is left.

Test plan

  • Integration tests. Both fail without the fix:
    • test_safe_navigation_migration_in_listener_reads_template_locals: @for item, @if … as alias, let- variable, root-view #ref, root-view @let. Each case asserts the whole listener body.
    • test_safe_navigation_migration_in_arrow_function_reads_template_locals: @for item and @if … as alias in a binding arrow function. Each case asserts the whole hoisted factory.
  • Compare fixtures in regressions/safe-navigation-migration.fixture.ts: safe-nav-migration-in-listener and safe-nav-migration-in-arrow-function. Both mismatch without the fix and match @angular/compiler 22.2.2 with it.
  • cargo test --all-features: 3267 passed, 0 failed.
  • cargo fmt --all -- --check, cargo run -p oxc_angular_conformance (1264/1264, no snapshot diff).
  • pnpm test (441/441), pnpm test:e2e (43 passed), pnpm --filter @oxc-angular/compare compare --fixtures (1091 total, 0 mismatched).
  • Checked in a real Angular 22.2.0 app's Cypress component tests (Vite 7): this bug failed 21 tests (a shared select component and @if … as activation toggles). A local build with this fix and the linker fix for Linker always uses the latest security schema: img|src/video|src sanitizers dropped for Angular < 22.2.2 #586 makes all of them pass.

🤖 Generated with Claude Code

edbzn and others added 3 commits October 19, 2026 15:41
…on` in listeners

A listener's `handler_expression` is resolved by calling `resolve_expression`
directly rather than through the generic expression visitor, and its explicit
match had no arm for the `SafeNavigationMigration` wrapper. Template locals in
the wrapped subtree (`@for` items, `@if ... as` aliases) were left unresolved
and later read from the listener context (`ctx.option`), so
`(click)="pick($safeNavigationMigration(option?.value))"` always passed `null`.

Fixes voidzero-dev#585

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…safeNavigationMigration`

- resolve_names: the wrapper arm comment now names both direct roots
  (a listener's handler_expression and hoisted arrow-function bodies).
- Listener test: add ng-template `let-`, root-view `#ref` and `@let` cases;
  assert the exact declaration and read for each local.
- New test for arrow functions: the hoisted factory restores the local from
  its own view and returns through `ɵɵresetView`.
- Move the compare fixture into safe-navigation-migration.fixture.ts and add
  more listener locals plus an arrow-function entry; both match Angular 22.2.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…igration` test

Assert every statement of each listener body as one block, as the arrow-function
test does. The old checks still passed when the root-view `#ref`/`@let`
listeners had no `ɵɵrestoreView`/`ɵɵresetView`, which is the pre-fix shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn Brooooooklyn changed the title fix(compiler): resolve template locals inside $safeNavigationMigration in listeners fix(compiler): resolve template locals inside $safeNavigationMigration in listeners and arrow functions Oct 20, 2026
@Brooooooklyn
Brooooooklyn merged commit 2759cd4 into voidzero-dev:main Oct 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$safeNavigationMigration(...) in an event listener reads template locals from the wrong context

2 participants