From ee9a4d9b7dbd7ead5f2aa0b1fbb59d2b6889cf51 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 11 Aug 2026 13:28:57 -0700 Subject: [PATCH] fix(compiler-cli): correctly resolve symbol for SafePropertyRead in chained optional navigation When resolving template symbols for SafePropertyRead in TCBs emitted with optional chaining (strictSafeNavigationTypes: true), SymbolBuilder falls back to finding a TS node matching the AST expression's nameSpan. It then traverses up through parent nodes to find the enclosing expression. Previously, the traversal loop checked isAccessExpression(node.parent) without verifying whether node was the accessed member name or the expression receiver. When multiple optional navigation expressions are chained (e.g. route?.data?.['icon']), the parent of ((route)?.data) is an access expression where ((route)?.data) is the receiver. Because isAccessExpression was true, the loop continued ascending into the outer access expression, causing symbol resolution for data to erroneously return the symbol and TCB location of icon. This commit refines the parent traversal condition so that it only climbs into a parent PropertyAccessExpression if node is the accessed name (node.parent.name === node), preventing escape into outer receiver expressions. --- .../typecheck/src/template_symbol_builder.ts | 8 +-- ...ecker__get_symbol_of_template_node_spec.ts | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index 9d2f2819c96..113e34e6795 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -658,11 +658,7 @@ export class SymbolBuilder { const expressionTarget = this.boundTarget.getExpressionTarget(expression); if (expressionTarget !== null) { return this.getSymbol(expressionTarget) as - | VariableSymbol - | ReferenceSymbol - | ExpressionSymbol - | LetDeclarationSymbol - | null; + VariableSymbol | ReferenceSymbol | ExpressionSymbol | LetDeclarationSymbol | null; } let withSpan = expression.sourceSpan; @@ -714,7 +710,7 @@ export class SymbolBuilder { node.parent !== undefined && (ts.isParenthesizedExpression(node.parent) || ts.isNonNullExpression(node.parent) || - isAccessExpression(node.parent)) + (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)) ) { node = node.parent; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts index fa008e36e5b..e33fcc315c7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts @@ -13,11 +13,13 @@ import { BindingPipe, Conditional, Interpolation, + KeyedRead, LiteralArray, LiteralMap, MatchSource, ParseTemplateOptions, PropertyRead, + SafeKeyedRead, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundText, @@ -878,6 +880,73 @@ runInEachFileSystem(() => { ).toEqual('data'); }); + it('safe property reads with optional chaining', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup( + [ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + interface Route { + data?: { icon: string; }; + } + export class Cmp { route?: Route; } + `, + }, + ], + {strictSafeNavigationTypes: true}, + ); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + const ast = (nodes[0].inputs[0].value as ASTWithSource).ast as SafeKeyedRead; + const dataRead = ast.receiver as SafePropertyRead; + const dataSymbol = templateTypeChecker.getSymbolOfNode(dataRead, cmp)!; + assertExpressionSymbol(dataSymbol); + expect( + program + .getTypeChecker() + .symbolToString(templateTypeChecker.getTsSymbolOfSymbol(dataSymbol)!), + ).toEqual('data'); + }); + + it('safe property reads used in element access argument', () => { + const fileName = absoluteFrom('/main.ts'); + const templateString = `
`; + const {templateTypeChecker, program} = setup( + [ + { + fileName, + templates: {'Cmp': templateString}, + source: ` + interface Route { + data: string; + } + export class Cmp { + route?: Route; + arr: Record = {}; + } + `, + }, + ], + {strictSafeNavigationTypes: true}, + ); + const sf = getSourceFileOrError(program, fileName); + const cmp = getClass(sf, 'Cmp'); + const nodes = getAstElements(templateTypeChecker, cmp); + const ast = (nodes[0].inputs[0].value as ASTWithSource).ast as KeyedRead; + const dataRead = ast.key as SafePropertyRead; + const dataSymbol = templateTypeChecker.getSymbolOfNode(dataRead, cmp)!; + assertExpressionSymbol(dataSymbol); + expect( + program + .getTypeChecker() + .symbolToString(templateTypeChecker.getTsSymbolOfSymbol(dataSymbol)!), + ).toEqual('data'); + }); + it('ternary expressions', () => { const nodes = getAstElements(templateTypeChecker, cmp);