Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
117 changes: 117 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`.
// (template, statements of the listener body, old context read)
let cases = [
(
r#"@for (option of options; track $index) {
<button (click)="pick($safeNavigationMigration(option?.value))"></button>
}"#,
[
"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) {
<button (click)="save($safeNavigationMigration(u?.id))"></button>
}"#,
[
"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#"<ng-template let-item>
<button (click)="pick($safeNavigationMigration(item?.value))"></button>
</ng-template>"#,
[
"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#"<input #box />
<button (click)="pick($safeNavigationMigration(box?.value))"></button>"#,
[
"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;
<button (click)="pick($safeNavigationMigration(v?.name))"></button>"#,
[
"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) {
<div [title]="fmt(() => $safeNavigationMigration(option?.value))"></div>
}"#,
"const option_r1 = i0.ɵɵrestoreView(view).$implicit;",
"return i0.ɵɵresetView(((option_r1 == null)? null: option_r1.value));",
),
(
r#"@if (user; as u) {
<div [title]="fmt(() => $safeNavigationMigration(u?.id))"></div>
}"#,
"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
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
<button (click)="pick($safeNavigationMigration(option?.value))">{{ option?.label }}</button>
<button (click)="pick($safeNavigationMigration(selected?.value))">Selected</button>
}
@if (user; as u) {
<button (click)="save($event, $safeNavigationMigration(u?.flags?.verified))">Save</button>
}
<ng-template let-item>
<button (click)="pick($safeNavigationMigration(item?.value))">Item</button>
</ng-template>
<input #box />
<button (click)="pick($safeNavigationMigration(box?.value))">Box</button>
@let firstOption = options[0];
<button (click)="pick($safeNavigationMigration(firstOption?.value))">First</button>
\`,
})
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) {
<div [title]="fmt(() => $safeNavigationMigration(option?.value))">
{{ fmt(() => $safeNavigationMigration(option?.label)) }}
</div>
}
\`,
})
export class SafeNavMigrationInArrowFunctionComponent {
options: Array<{ value: string; label: string } | null> = [];
fmt(fn: () => string | null) {
return fn() ?? '';
}
}
`.trim(),
expectedFeatures: ['ɵɵarrowFunction', 'ɵɵrestoreView', 'ɵɵresetView'],
},
]
Loading