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
26 changes: 26 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/extended/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
ParseSourceSpan,
TmplAstNode,
TmplAstTemplate,
KeyedRead,
SafePropertyRead,
SafeKeyedRead,
SafeCall,
ParenthesizedExpression,
NonNullAssert,
} from '@angular/compiler';
import ts from 'typescript';

Expand Down Expand Up @@ -161,3 +167,23 @@ class TemplateVisitor<Code extends ErrorCode> extends CombinedRecursiveAstVisito
return this.diagnostics;
}
}

/**
* Checks if the given AST node originates from a KeyedRead (indexed access)
* by unwrapping parentheses, non-null assertions, and traversing the receivers
* of safe navigation operations (?. property access, ?.[] keyed access, ?.() calls).
*/
export function isAccessFromUncheckedIndex(node: AST): boolean {
if (node instanceof KeyedRead) {
return true;
} else if (
node instanceof SafePropertyRead ||
node instanceof SafeKeyedRead ||
node instanceof SafeCall
) {
return isAccessFromUncheckedIndex(node.receiver);
} else if (node instanceof ParenthesizedExpression || node instanceof NonNullAssert) {
return isAccessFromUncheckedIndex(node.expression);
}
return false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {AST, Binary, TmplAstNode} from '@angular/compiler';
import {AST, Binary, KeyedRead, TmplAstNode} from '@angular/compiler';
import ts from 'typescript';

import {NgCompilerOptions} from '../../../../core/api';
Expand All @@ -17,6 +17,7 @@ import {
TemplateCheckWithVisitor,
TemplateContext,
formatExtendedError,
isAccessFromUncheckedIndex,
} from '../../api';

/**
Expand All @@ -28,13 +29,23 @@ import {
class NullishCoalescingNotNullableCheck extends TemplateCheckWithVisitor<ErrorCode.NULLISH_COALESCING_NOT_NULLABLE> {
override code = ErrorCode.NULLISH_COALESCING_NOT_NULLABLE as const;

constructor(private readonly noUncheckedIndexedAccess: boolean) {
super();
}

override visitNode(
ctx: TemplateContext<ErrorCode.NULLISH_COALESCING_NOT_NULLABLE>,
component: ts.ClassDeclaration,
node: TmplAstNode | AST,
): NgTemplateDiagnostic<ErrorCode.NULLISH_COALESCING_NOT_NULLABLE>[] {
if (!(node instanceof Binary) || node.operation !== '??') return [];

// When `noUncheckedIndexedAccess` is disabled, an indexed access is not checked
Comment thread
JeanMeche marked this conversation as resolved.
// and may result in `undefined`.
if (!this.noUncheckedIndexedAccess && isAccessFromUncheckedIndex(node.left)) {
return [];
}

const symbolLeft = ctx.templateTypeChecker.getSymbolOfNode(node.left, component);
if (symbolLeft === null || symbolLeft.kind !== SymbolKind.Expression) {
return [];
Expand Down Expand Up @@ -86,6 +97,8 @@ export const factory: TemplateCheckFactory<
return null;
}

return new NullishCoalescingNotNullableCheck();
const noUncheckedIndexedAccess = !!options.noUncheckedIndexedAccess;

return new NullishCoalescingNotNullableCheck(noUncheckedIndexedAccess);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TemplateCheckWithVisitor,
TemplateContext,
formatExtendedError,
isAccessFromUncheckedIndex,
} from '../../api';

/**
Expand Down Expand Up @@ -54,7 +55,7 @@ class OptionalChainNotNullableCheck extends TemplateCheckWithVisitor<ErrorCode.O

// When `noUncheckedIndexedAccess` is disabled, an indexed access is not checked
// and may result in `undefined`.
if (node.receiver instanceof KeyedRead && !this.noUncheckedIndexedAccess) {
if (!this.noUncheckedIndexedAccess && isAccessFromUncheckedIndex(node.receiver)) {
return [];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,155 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should not produce nullish coalescing warning for an indexed access if noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ arr[0] ?? 'foo' }}`,
},
source: `
export class TestCmp {
arr: Array<string> = [];
}
`,
},
],
{options: {noUncheckedIndexedAccess: false}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should not produce nullish coalescing warning for an indexed access if noUncheckedIndexedAccess is true', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ arr[0] ?? 'foo' }}`,
},
source: `
export class TestCmp {
arr: Array<string> = [];
}
`,
},
],
{options: {noUncheckedIndexedAccess: true}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should not produce nullish coalescing warning for a SafePropertyRead off an indexed access if noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ foos[0]?.name ?? 'foo' }}`,
},
source: `
export class TestCmp {
foos: Array<{name: string}> = [];
}
`,
},
],
{options: {noUncheckedIndexedAccess: false}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should not produce nullish coalescing warning for a SafeKeyedRead if noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ foos?.[0] ?? 'foo' }}`,
},
source: `
export class TestCmp {
foos: Array<string> | null = null;
}
`,
},
],
{options: {noUncheckedIndexedAccess: false}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should not produce nullish coalescing warning for a parenthesized indexed access if noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ (foos[0]) ?? 'foo' }}`,
},
source: `
export class TestCmp {
foos: Array<string> = [];
}
`,
},
],
{options: {noUncheckedIndexedAccess: false}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[nullishCoalescingNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});
it('warns for pipe arguments which are likely configured incorrectly (?? operates on "format" here)', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
Expand Down Expand Up @@ -341,7 +490,7 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should produce nullish coalescing warning for a non-nullable ElementAccessExpression', () => {
it('should not produce nullish coalescing warning for a non-nullable ElementAccessExpression when noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
Expand All @@ -362,10 +511,7 @@ runInEachFileSystem(() => {
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(1);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.NULLISH_COALESCING_NOT_NULLABLE));
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`myDict[key] ?? 'foo'`);
expect(diags.length).toBe(0);
});

it('should respect configured diagnostic category', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,36 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should not produce optional chain warning for a chained safe property read off an indexed access if noUncheckedIndexedAccess is false', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {
'TestCmp': `{{ arr[0]?.bar?.length }}`,
},
source: `
export class TestCmp {
arr: Array<{ bar: string }> = [];
}
`,
},
],
{options: {noUncheckedIndexedAccess: false}},
);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[optionalChainNotNullableFactory],
{strictNullChecks: true} /* options */,
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should produce optional chain warning for an indexed access if noUncheckedIndexedAccess is true', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup(
Expand Down