diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs index ddd9be122..b51d0605f 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs @@ -877,6 +877,20 @@ fn resolve_expression<'a>( ); } + // `$safeNavigationMigration(...)` wrapper - resolve the wrapped expression. A listener's + // `handler_expression` and hoisted arrow-function bodies reach this function directly, + // not through the generic visitor, so the wrapper must be descended here. + IrExpression::SafeNavigationMigration(migration) => { + resolve_expression( + migration.expr.as_mut(), + scope, + root_xref, + saved_view, + allocator, + expressions, + ); + } + // ResolvedTemplateLiteral (created by ingest for template literals with inner expressions) // - resolve each inner expression so LexicalRead refs to @let vars and pipe args are resolved IrExpression::ResolvedTemplateLiteral(tl) => { diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 2c2283ca6..c52f33f6b 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -2339,6 +2339,123 @@ fn test_safe_navigation_migration_ignores_qualified_call() { ); } +#[test] +fn test_safe_navigation_migration_in_listener_reads_template_locals() { + // Inside a listener, the wrapped expression must resolve template locals + // (`@for` items, `@if ... as` aliases, `let-` variables, `#ref`s, `@let`s) to + // the restored-view variables, like any other listener expression, instead of + // falling back to `ctx.`. + // (template, statements of the listener body, old context read) + let cases = [ + ( + r#"@for (option of options; track $index) { + +}"#, + [ + "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)));", + ], + "ctx.option.value", + ), + ( + r#"@if (user; as u) { + +}"#, + [ + "const u_r2 = i0.ɵɵrestoreView(_r1);", + "const ctx_r2 = i0.ɵɵnextContext();", + "return i0.ɵɵresetView(ctx_r2.save(((u_r2 == null)? null: u_r2.id)));", + ], + "ctx.u.id", + ), + ( + r#" + +"#, + [ + "const item_r2 = i0.ɵɵrestoreView(_r1).$implicit;", + "const ctx_r2 = i0.ɵɵnextContext();", + "return i0.ɵɵresetView(ctx_r2.pick(((item_r2 == null)? null: item_r2.value)));", + ], + "ctx.item.value", + ), + ( + r#" +"#, + [ + "i0.ɵɵrestoreView(_r1);", + "const box_r2 = i0.ɵɵreference(1);", + "return i0.ɵɵresetView(ctx.pick(((box_r2 == null)? null: box_r2.value)));", + ], + "ctx.box.value", + ), + ( + r#"@let v = user?.profile; +"#, + [ + "i0.ɵɵrestoreView(_r1);", + "const v_r2 = i0.ɵɵreadContextLet(0);", + "return i0.ɵɵresetView(ctx.pick(((v_r2 == null)? null: v_r2.name)));", + ], + "ctx.v.name", + ), + ]; + for (template, statements, old_read) in cases { + let js = compile_template_to_js_with_version( + template, + "TestComponent", + Some(AngularVersion::new(22, 0, 0)), + ); + let body = format!("_listener() {{\n {}\n }});", statements.join("\n ")); + assert!(js.contains(&body), "expected the listener body\n{body}\ngot:\n{js}"); + assert!(!js.contains(old_read), "must not read `{old_read}`, got:\n{js}"); + assert!( + !js.contains("$safeNavigationMigration"), + "the wrapper must be stripped, got:\n{js}" + ); + } +} + +#[test] +fn test_safe_navigation_migration_in_arrow_function_reads_template_locals() { + // A template arrow function is hoisted into a module-level factory that gets + // the view as a parameter. The wrapped expression in its body must restore + // the local from that view (and return through `ɵɵresetView`) instead of + // reading a free variable that only exists inside the template function. + // (template, declaration of the local, return of the factory) + let cases = [ + ( + r#"@for (option of options; track $index) { +
+}"#, + "const option_r1 = i0.ɵɵrestoreView(view).$implicit;", + "return i0.ɵɵresetView(((option_r1 == null)? null: option_r1.value));", + ), + ( + r#"@if (user; as u) { +
+}"#, + "const u_r1 = i0.ɵɵrestoreView(view);", + "return i0.ɵɵresetView(((u_r1 == null)? null: u_r1.id));", + ), + ]; + for (template, declaration, ret) in cases { + let js = compile_template_to_js_with_version( + template, + "TestComponent", + Some(AngularVersion::new(22, 0, 0)), + ); + let factory = + format!("const arrowFn0 = (ctx,view) =>() =>{{\n {declaration}\n {ret}\n}};"); + assert!(js.contains(&factory), "expected the arrow factory\n{factory}\ngot:\n{js}"); + assert!( + !js.contains("$safeNavigationMigration"), + "the wrapper must be stripped, got:\n{js}" + ); + } +} + // ============================================================================ // Event Modifier Tests // ============================================================================ diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/safe-navigation-migration.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/safe-navigation-migration.fixture.ts new file mode 100644 index 000000000..7bec14418 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/safe-navigation-migration.fixture.ts @@ -0,0 +1,79 @@ +/** + * Regression: Template locals inside $safeNavigationMigration. + * + * Issue: Template locals read inside $safeNavigationMigration(...) in + * listeners and arrow functions must resolve to the restored-view + * variables instead of the component context (#585). + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'safe-nav-migration-in-listener', + category: 'regressions', + description: '$safeNavigationMigration in listeners reading template locals', + className: 'SafeNavMigrationInListenerComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-safe-nav-migration-in-listener', + standalone: true, + template: \` + @for (option of options; track $index) { + + + } + @if (user; as u) { + + } + + + + + + @let firstOption = options[0]; + + \`, +}) +export class SafeNavMigrationInListenerComponent { + options: Array<{ value: string; label: string } | null> = []; + selected: { value: string } | null = null; + user: { flags?: { verified: boolean } } | null = null; + pick(value: string | null) {} + save(event: Event, verified: boolean | null) {} +} + `.trim(), + expectedFeatures: ['ɵɵlistener', 'ɵɵreference', 'ɵɵreadContextLet'], + }, + { + name: 'safe-nav-migration-in-arrow-function', + category: 'regressions', + description: '$safeNavigationMigration in arrow functions reading template locals', + className: 'SafeNavMigrationInArrowFunctionComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-safe-nav-migration-in-arrow-function', + standalone: true, + template: \` + @for (option of options; track $index) { +
+ {{ fmt(() => $safeNavigationMigration(option?.label)) }} +
+ } + \`, +}) +export class SafeNavMigrationInArrowFunctionComponent { + options: Array<{ value: string; label: string } | null> = []; + fmt(fn: () => string | null) { + return fn() ?? ''; + } +} + `.trim(), + expectedFeatures: ['ɵɵarrowFunction', 'ɵɵrestoreView', 'ɵɵresetView'], + }, +]