Skip to content

Commit 263feba

Browse files
dgp1130dylhunn
authored andcommitted
fix(compiler-cli): handle nullable expressions correctly in the nullish coalescing extended template diagnostic (angular#43572)
Refs angular#42966. Previously, checking a template with the syntax: ```html <div>{{ foo() ?? 'test' }}</div> ``` Where `foo()` returns a nullable value: ```typescript @component(/* ... */) class TestCmp { foo: (): string | null => null; } ``` Would always log a nullish coalescing not nullable warning. This is because [`getSymbolOfNode(node.left)`](https://github.com/angular/angular/blob/fe691935091aaf7090864c8111a15f7cc7e53b6c/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/nullish_coalescing_not_nullable/index.ts#L30) would return the [symbol of the function (`foo`)](https://github.com/angular/angular/blob/fe691935091aaf7090864c8111a15f7cc7e53b6c/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts#L536-L538) rather than the symbol of its returned value (`foo()`). Fixed this by getting the symbol for the whole expression's span, rather than just the function receiver. Also made some minor refactorings to `template_symbol_builder` to make a similar change to safe method calls. This behavior was originally for the language service in order to handle quick info, as the user highlighting a function name would actually apply to the entire expression. This is no longer true as the language service will correctly request the type from the function rather than the `Call` expression, so these hacks are not necessary anymore. This broke two existing test cases of exactly this behavior which were easily updated. Also added a test to the language service to confirm that it is not broken by this change. PR Close angular#43572
1 parent 7fd0428 commit 263feba

4 files changed

Lines changed: 52 additions & 43 deletions

File tree

packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/nullish_coalescing_not_nullable/nullish_coalescing_not_nullable_spec.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -133,25 +133,29 @@ runInEachFileSystem(() => {
133133
expect(diags.length).toBe(0);
134134
});
135135

136-
it('does not blow up when there is no symbol for the LHS of the operator', () => {
137-
const fileName = absoluteFrom('/main.ts');
138-
const {program, templateTypeChecker} = setup([
139-
{
140-
fileName,
141-
templates: {
142-
'TestCmp': `{{ (123 | doesNotExist) ?? 'invalid date' }}`,
143-
},
144-
source: `
145-
export class TestCmp { var1: string | undefined = "text"; }
146-
`,
147-
},
148-
]);
149-
const sf = getSourceFileOrError(program, fileName);
150-
const component = getClass(sf, 'TestCmp');
151-
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
152-
templateTypeChecker, program.getTypeChecker(), [new NullishCoalescingNotNullableCheck()]);
153-
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
154-
expect(diags.length).toBe(0);
155-
});
136+
it('should not produce nullish coalescing warning when the left side is a nullable expression',
137+
() => {
138+
const fileName = absoluteFrom('/main.ts');
139+
const {program, templateTypeChecker} = setup([
140+
{
141+
fileName,
142+
templates: {
143+
'TestCmp': `{{ func() ?? 'foo' }}`,
144+
},
145+
source: `
146+
export class TestCmp {
147+
func: (): string | null => null;
148+
}
149+
`,
150+
},
151+
]);
152+
const sf = getSourceFileOrError(program, fileName);
153+
const component = getClass(sf, 'TestCmp');
154+
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
155+
templateTypeChecker, program.getTypeChecker(),
156+
[new NullishCoalescingNotNullableCheck()]);
157+
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
158+
expect(diags.length).toBe(0);
159+
});
156160
});
157161
});

packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,6 @@ export class SymbolBuilder {
499499
// AST so there is no way to retrieve a `Symbol` for just the `name` via a specific node.
500500
if (expression instanceof PropertyWrite) {
501501
withSpan = expression.nameSpan;
502-
} else if (expression instanceof Call && expression.receiver instanceof PropertyRead) {
503-
withSpan = expression.receiver.nameSpan;
504502
}
505503

506504
let node: ts.Node|null = null;
@@ -530,12 +528,8 @@ export class SymbolBuilder {
530528
// - If our expression is a pipe binding ("a | test:b:c"), we want the Symbol for the
531529
// `transform` on the pipe.
532530
// - Otherwise, we retrieve the symbol for the node itself with no special considerations
533-
if ((expression instanceof SafePropertyRead ||
534-
(expression instanceof Call && expression.receiver instanceof SafePropertyRead)) &&
535-
ts.isConditionalExpression(node)) {
536-
const whenTrueSymbol = (expression instanceof Call && ts.isCallExpression(node.whenTrue)) ?
537-
this.getSymbolOfTsNode(node.whenTrue.expression) :
538-
this.getSymbolOfTsNode(node.whenTrue);
531+
if (expression instanceof SafePropertyRead && ts.isConditionalExpression(node)) {
532+
const whenTrueSymbol = this.getSymbolOfTsNode(node.whenTrue);
539533
if (whenTrueSymbol === null) {
540534
return null;
541535
}

packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -467,13 +467,10 @@ runInEachFileSystem(() => {
467467
const safeMethodCall = nodes[2].inputs[0].value as ASTWithSource;
468468
const methodCallSymbol = templateTypeChecker.getSymbolOfNode(safeMethodCall, cmp)!;
469469
assertExpressionSymbol(methodCallSymbol);
470-
expect(program.getTypeChecker().symbolToString(methodCallSymbol.tsSymbol!))
471-
.toEqual('speak');
472-
expect((methodCallSymbol.tsSymbol!.declarations![0] as ts.PropertyDeclaration)
473-
.parent.name!.getText())
474-
.toEqual('Person');
470+
// Note that the symbol returned is for the return value of the safe method call.
471+
expect(methodCallSymbol.tsSymbol).toBeNull();
475472
expect(program.getTypeChecker().typeToString(methodCallSymbol.tsType))
476-
.toEqual('string | undefined');
473+
.toBe('string | undefined');
477474
});
478475

479476
it('safe keyed reads', () => {
@@ -869,14 +866,9 @@ runInEachFileSystem(() => {
869866
const node = getAstElements(templateTypeChecker, cmp)[0];
870867
const callSymbol = templateTypeChecker.getSymbolOfNode(node.inputs[0].value, cmp)!;
871868
assertExpressionSymbol(callSymbol);
872-
// Note that the symbol returned is for the method name of the Call. The AST
873-
// does not support specific designation for the name so we assume that's what
874-
// is wanted in this case. We don't support retrieving a symbol for the whole
875-
// call expression and if you want to get a symbol for the args, you can
876-
// use the AST of the args in the `Call`.
877-
expect(program.getTypeChecker().symbolToString(callSymbol.tsSymbol!)).toEqual('toString');
878-
expect(program.getTypeChecker().typeToString(callSymbol.tsType))
879-
.toEqual('(v: any) => string');
869+
// Note that the symbol returned is for the return value of the Call.
870+
expect(callSymbol.tsSymbol).toBeNull();
871+
expect(program.getTypeChecker().typeToString(callSymbol.tsType)).toBe('string');
880872
});
881873
});
882874

packages/language-service/ivy/test/quick_info_spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
1010

1111
import * as ts from 'typescript/lib/tsserverlibrary';
12-
import {LanguageServiceTestEnv, Project} from '../testing';
12+
import {createModuleAndProjectWithDeclarations, LanguageServiceTestEnv, Project} from '../testing';
1313

1414
function quickInfoSkeleton(): {[fileName: string]: string} {
1515
return {
@@ -443,6 +443,25 @@ describe('quick info', () => {
443443
});
444444
});
445445

446+
it('should work for safe method calls', () => {
447+
const files = {
448+
'app.ts': `import {Component} from '@angular/core';
449+
@Component({template: '<div (click)="something?.myFunc()"></div>'})
450+
export class AppCmp {
451+
something!: {
452+
/** Documentation for myFunc. */
453+
myFunc(): void
454+
};
455+
}`,
456+
};
457+
const project = createModuleAndProjectWithDeclarations(env, 'test_project', files);
458+
const appFile = project.openFile('app.ts');
459+
appFile.moveCursorToText('something?.myF¦unc()');
460+
const info = appFile.getQuickInfoAtPosition()!;
461+
expect(toText(info.displayParts)).toEqual('(method) myFunc(): void');
462+
expect(toText(info.documentation)).toEqual('Documentation for myFunc.');
463+
});
464+
446465
it('should work for accessed properties in writes', () => {
447466
expectQuickInfo({
448467
templateOverride: `<div (click)="hero.i¦d = 2"></div>`,

0 commit comments

Comments
 (0)