Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,63 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should produce optional chain warning for chained property access', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `{{ var1?.bar.baz }}`,
},
source:
'export class TestCmp { var1: { bar: { baz: string } } = { bar: { baz: "text" } }; }',
},
]);
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(1);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.OPTIONAL_CHAIN_NOT_NULLABLE));
expect(diags[0].messageText).toContain(
`the '?.' operator can be replaced with the '.' operator`,
);
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`bar`);
});

it('should not produce a warning on property access with consecutive optional chaining when the root is nullable', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `{{ var1?.bar?.baz }} {{ var1?.bar.baz }} {{ var2?.bar().baz }}`,
},
source: `
export class TestCmp {
var1: { bar: { baz: string } } | null = null;
var2: { bar: () => { baz: string } } | null = null;
}
`,
},
]);
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 not produce a warning on function calls with consecutive optional chaining with legacy behavior', () => {});
});
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('type check blocks diagnostics', () => {
it('should annotate safe calls', () => {
const TEMPLATE = `{{ method?.(a, b) }}`;
expect(tcbWithSpans(TEMPLATE)).toContain(
'(((((this).method /*3,9*/) /*3,9*/)?.(((this).a /*12,13*/) /*12,13*/, ((this).b /*15,16*/) /*15,16*/)) /*3,17*/)',
'((((this).method /*3,9*/) /*3,9*/)?.(((this).a /*12,13*/) /*12,13*/, ((this).b /*15,16*/) /*15,16*/) /*3,17*/)',
);
});

Expand Down Expand Up @@ -146,7 +146,7 @@ describe('type check blocks diagnostics', () => {
it('should annotate safe method calls', () => {
const TEMPLATE = `{{ a?.method(b) }}`;
expect(tcbWithSpans(TEMPLATE)).toContain(
'(((((this).a /*3,4*/) /*3,4*/)?.method /*6,12*/ /*3,12*/?.(((this).b /*13,14*/) /*13,14*/)) /*3,15*/)',
'((((this).a /*3,4*/) /*3,4*/)?.method /*6,12*/ /*3,12*/?.(((this).b /*13,14*/) /*13,14*/) /*3,15*/)',
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1509,10 +1509,13 @@ describe('type check blocks', () => {
expect(block).toContain('((((((this).a)).optionalMethod))!() as any)');
});

it('should produce correct correct ts expression', () => {
const TEMPLATE = `{{ one?.two.three }}`;
const block = tcb(TEMPLATE, DIRECTIVES);
expect(block).toContain('(((((this).one))?.two.three))');
it('should produce correct ts expression without extra parentheses for safe navigation chains', () => {
expect(tcb(`{{ a?.b.c }}`, DIRECTIVES)).toContain('((((this).a))?.b.c)');
expect(tcb(`{{ a?.b.c.d }}`, DIRECTIVES)).toContain('((((this).a))?.b.c.d)');
expect(tcb(`{{ a?.b?.c }}`, DIRECTIVES)).toContain('(((((this).a))?.b)?.c)'); // Safe property read receiver wraps
expect(tcb(`{{ a?.b['c'].d }}`, DIRECTIVES)).toContain('((((this).a))?.b["c"].d)');
expect(tcb(`{{ a?.b().c }}`, DIRECTIVES)).toContain('((((this).a))?.b?.().c)'); // convertToSafeCall no longer wraps
expect(tcb(`{{ a?.b?.().c }}`, DIRECTIVES)).toContain('(((((this).a))?.b)?.().c)'); // SafeCall receiver wraps
});
});

Expand Down Expand Up @@ -3181,7 +3184,7 @@ describe('type check blocks', () => {
'_t1.value[i1.ɵINPUT_SIGNAL_BRAND_WRITE_TYPE] = i1.ɵunwrapWritableSignal((((((this).f)()).value)));',
);
expect(block).toContain(
'_t1.max[i1.ɵINPUT_SIGNAL_BRAND_WRITE_TYPE] = (((((((this).f)()).max))?.()));',
'_t1.max[i1.ɵINPUT_SIGNAL_BRAND_WRITE_TYPE] = ((((((this).f)()).max))?.());',
);
expect(block).toContain('var _t2 = null! as i0.FormField;');
expect(block).toContain('_t2.field = (((this).f));');
Expand Down
40 changes: 40 additions & 0 deletions packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,46 @@ runInEachFileSystem(() => {
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});

it('should properly short-circuit safe navigation chains', () => {
env.tsconfig({
fullTemplateTypeCheck: true,
strictTemplates: true,
strictSafeNavigationTypes: true,
});

env.write(
'test.ts',
`
import {Component, NgModule} from '@angular/core';

type MyType = {
data: {
foo: {
bar: boolean;
};
};
};

@Component({
selector: 'test',
template: '{{ value?.data.foo.bar }}',
standalone: false,
})
class TestCmp {
value: MyType | null = null;
}
Comment thread
JeanMeche marked this conversation as resolved.

@NgModule({
declarations: [TestCmp],
})
class Module {}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});

describe('strictOutputEventTypes', () => {
Expand Down
48 changes: 38 additions & 10 deletions packages/compiler/src/typecheck/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,20 @@ class TcbExprTranslator implements AstVisitor {

visitPropertyRead(ast: PropertyRead): TcbExpr {
const receiver = this.translate(ast.receiver);
if (!this.isStrictSafeNavigationChain(ast.receiver)) {
const isSafeChain = this.isStrictSafeNavigationChain(ast.receiver);
if (!isSafeChain) {
receiver.wrapForTypeChecker();
}
return new TcbExpr(`${receiver.print()}.${ast.name}`)
.addParseSpanInfo(ast.nameSpan)
.wrapForTypeChecker()
.addParseSpanInfo(ast.sourceSpan);

const node = new TcbExpr(`${receiver.print()}.${ast.name}`).addParseSpanInfo(ast.nameSpan);

let result: TcbExpr;
if (isSafeChain) {
result = new TcbExpr(node.print());
} else {
result = node.wrapForTypeChecker();
}
return result.addParseSpanInfo(ast.sourceSpan);
}

visitSafePropertyRead(ast: SafePropertyRead): TcbExpr {
Expand Down Expand Up @@ -410,7 +417,7 @@ class TcbExprTranslator implements AstVisitor {
const args = argNodes.map((node) => node.print()).join(', ');

if (this.config.strictSafeNavigationTypes) {
return new TcbExpr(`(${expr}?.(${args}))`);
return new TcbExpr(`${expr}?.(${args})`);
}

if (VeSafeLhsInferenceBugDetector.veWillInferAnyFor(ast)) {
Expand All @@ -425,10 +432,31 @@ class TcbExprTranslator implements AstVisitor {
}

private isStrictSafeNavigationChain(ast: AST): boolean {
return (
this.config.strictSafeNavigationTypes &&
(ast instanceof SafePropertyRead || ast instanceof SafeKeyedRead || ast instanceof SafeCall)
);
if (!this.config.strictSafeNavigationTypes) {
return false;
}
let current: AST | undefined = ast;
while (current) {
if (
current instanceof SafePropertyRead ||
current instanceof SafeKeyedRead ||
current instanceof SafeCall
) {
return true;
}
if (
current instanceof PropertyRead ||
current instanceof KeyedRead ||
current instanceof Call
) {
current = current.receiver;
} else if (current instanceof NonNullAssert) {
current = current.expression;
} else {
break;
}
}
return false;
}
}

Expand Down