8000 feat(compiler): add support for shorthand property declarations in templates by crisbeto · Pull Request #42421 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

feat(compiler): add support for shorthand property declarations in templates #42421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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 @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AST, ASTWithSource, BindingPipe, MethodCall, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import {AST, ASTWithSource, BindingPipe, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import * as ts from 'typescript';

import {AbsoluteFsPath} from '../../file_system';
Expand Down Expand Up @@ -482,8 +482,20 @@ export class SymbolBuilder {
expression.nameSpan :
expression.sourceSpan;

let node = findFirstMatchingNode(
this.typeCheckBlock, {withSpan, filter: (n: ts.Node): n is ts.Node => true});
let node: ts.Node|null = null;

// Property reads in templates usually map to a `PropertyAccessExpression`
// (e.g. `ctx.foo`) so try looking for one first.
if (expression instanceof PropertyRead) {
node = findFirstMatchingNode(
this.typeCheckBlock, {withSpan, filter: ts.isPropertyAccessExpression});
}

// Otherwise fall back to searching for any AST node.
if (node === null) {
node = findFirstMatchingNode(this.typeCheckBlock, {withSpan, filter: anyNodeFilter});
}

if (node === null) {
return null;
}
8000 Expand Down Expand Up @@ -560,3 +572,8 @@ export class SymbolBuilder {
}
}
}

/** Filter predicate function that matches any AST node. */
function anyNodeFilter(n: ts.Node): n is ts.Node {
return true;
}
44 changes: 44 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
< 8000 /tr>
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,50 @@ class TestComponent {
`TestComponent.html(4, 18): Property 'heihgt' does not exist on type 'TestComponent'. Did you mean 'height'?`,
]);
});

it('works for shorthand property declarations', () => {
const messages = diagnose(
`<div dir [input]="{a, b: 2}"></div>`, `
class Dir {
input: {a: string, b: number};
}
class TestComponent {
a: number;
}`,
[{
type: 'directive',
name: 'Dir',
selector: '[dir]',
exportAs: ['dir'],
inputs: {input: 'input'},
}]);

expect(messages).toEqual(
[`TestComponent.html(1, 20): Type 'number' is not assignable to type 'string'.`]);
});

it('works for shorthand property declarations referring to template variables', () => {
const messages = diagnose(
`
<span #span></span>
<div dir [input]="{span, b: 2}"></div>
`,
`
class Dir {
input: {span: string, b: number};
}
class TestComponent {}`,
[{
type: 'directive',
name: 'Dir',
selector: '[dir]',
exportAs: ['dir'],
inputs: {input: 'input'},
}]);

expect(messages).toEqual(
[`TestComponent.html(3, 30): Type 'HTMLElement' is not assignable to type 'string'.`]);
});
});

describe('method call spans', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ describe('type check blocks diagnostics', () => {
'(ctx).m /*3,4*/({ "foo": ((ctx).a /*11,12*/) /*11,12*/, "bar": ((ctx).b /*19,20*/) /*19,20*/ } /*5,21*/) /*3,22*/');
});

it('should annotate literal map expressions with shorthand declarations', () => {
// The additional method call is present to avoid that the object literal is emitted as
// statement, which would wrap it into parenthesis that clutter the expected output.
const TEMPLATE = '{{ m({a, b}) }}';
expect(tcbWithSpans(TEMPLATE))
.toContain(
'((ctx).m /*3,4*/({ "a": ((ctx).a /*6,7*/) /*6,7*/, "b": ((ctx).b /*9,10*/) /*9,10*/ } /*5,11*/) /*3,12*/)');
});

it('should annotate literal array expressions', () => {
const TEMPLATE = '{{ [a, b] }}';
expect(tcbWithSpans(TEMPLATE))
Expand Down Expand Up @@ -129,6 +138,13 @@ describe('type check blocks diagnostics', () => {
'(null as any ? (((ctx).a /*3,4*/) /*3,4*/)!.method /*6,12*/(((ctx).b /*13,14*/) /*13,14*/) : undefined) /*3,15*/');
});

it('should annotate safe keyed reads', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test appears unrelated to everything else in this PR. Is it here because prior designs broke this feature?

Either way, I would recommend extracting the addition of this test as a separate commit prior to the main commit, to avoid confusion.

Copy link
Member Author
@crisbeto crisbeto Jun 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are tests that I missed in my safe keyed reads PR (#41911). I decided to add them here since this PR touches the same test files. I would rather keep them as is for now, because untangling them from the various fixup commits will take a while and may involve having to squash the commits.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should squash the commits and then extract these tests into a separate commit, honestly.

It's not a big deal, but I think in the name of having a clean history, it makes sense not to bundle unrelated tests.

To avoid another round trip and blocking this PR, I'll go ahead and do that for you since I think it's ready to merge otherwise.

const TEMPLATE = `{{ a?.[0] }}`;
expect(tcbWithSpans(TEMPLATE))
.toContain(
'(null as any ? (((ctx).a /*3,4*/) /*3,4*/)![0 /*7,8*/] /*3,9*/ : undefined) /*3,9*/');
});

it('should annotate $any casts', () => {
const TEMPLATE = `{{ $any(a) }}`;
expect(tcbWithSpans(TEMPLATE)).toContain('(((ctx).a /*8,9*/) /*8,9*/ as any) /*3,10*/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ runInEachFileSystem(() => {
<div [inputA]="person?.address?.street"></div>
<div [inputA]="person ? person.address : noPersonError"></div>
<div [inputA]="person?.speak()"></div>
<div [inputA]="person?.cars?.[1].engine"></div>
`;
const testValues = setup(
[
Expand All @@ -405,9 +406,14 @@ runInEachFileSystem(() => {
street: string;
}

interface Car {
engine: string;
}

interface Person {
address: Address;
speak(): string;
cars?: Car[];
}
export class Cmp {person?: Person; noPersonError = 'no person'}
`,
Expand Down Expand Up @@ -448,6 +454,19 @@ runInEachFileSystem(() => {
.toEqual('string | undefined');
});

it('safe keyed reads', () => {
const nodes = getAstElements(templateTypeChecker, cmp);
const safeKeyedRead = nodes[3].inputs[0].value as ASTWithSource;
const keyedReadSymbol = templateTypeChecker.getSymbolOfNode(safeKeyedRead, cmp)!;
assertExpressionSymbol(keyedReadSymbol);
expect(program.getTypeChecker().symbolToString(keyedReadSymbol.tsSymbol!))
.toEqual('engine');
expect((keyedReadSymbol.tsSymbol!.declarations![0] as ts.PropertyDeclaration)
.parent.name!.getText())
.toEqual('Car');
expect(program.getTypeChecker().typeToString(keyedReadSymbol.tsType)).toEqual('string');
});

it('ternary expressions', () => {
const nodes = getAstElements(templateTypeChecker, cmp);

Expand Down Expand Up @@ -649,12 +668,16 @@ runInEachFileSystem(() => {
const fileName = absoluteFrom('/main.ts');
const templateString = `
{{ [1, 2, 3] }}
{{ { hello: "world" } }}`;
{{ { hello: "world" } }}
{{ { foo } }}`;
const testValues = setup([
{
fileName,
templates: {'Cmp': templateString},
source: `export class Cmp {}`,
source: `
type Foo {name: string;}
export class Cmp {foo: Foo;}
`,
},
]);
templateTypeChecker = testValues.templateTypeChecker;
Expand Down Expand Up @@ -682,6 +705,15 @@ runInEachFileSystem(() => {
expect(program.getTypeChecker().typeToString(symbol.tsType))
.toEqual('{ hello: string; }');
});

it('literal map shorthand property', () => {
const shorthandProp =
(interpolation.expressions[2] as LiteralMap).values[0] as PropertyRead;
const symbol = templateTypeChecker.getSymbolOfNode(shorthandProp, cmp)!;
assertExpressionSymbol(symbol);
expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('foo');
expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('Foo');
});
});

describe('pipes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -871,3 +871,54 @@ export declare class MyModule {
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

/****************************************************************************************************
* PARTIAL FILE: shorthand_property_declaration.js
****************************************************************************************************/
import { Component, NgModule } from '@angular/core';
import * as i0 from "@angular/core";
export class MyComponent {
constructor() {
this.a = 1;
this.c = 3;
}
_handleClick(_value) { }
}
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, selector: "ng-component", ngImport: i0, template: `
<div (click)="_handleClick({a, b: 2, c})"></div>
`, isInline: true });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
type: Component,
args: [{
template: `
<div (click)="_handleClick({a, b: 2, c})"></div>
`
}]
}] });
export class MyModule {
}
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyComponent] });
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
type: NgModule,
args: [{ declarations: [MyComponent] }]
}] });

/****************************************************************************************************
* PARTIAL FILE: shorthand_property_declaration.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class MyComponent {
a: number;
c: number;
_handleClick(_value: any): void;
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyComponent], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,23 @@
"failureMessage": "Incorrect template"
}
]
},
{
"description": "should handle shorthand property declarations in templates",
"inputFiles": [
"shorthand_property_declaration.ts"
],
"expectations": [
{
"files": [
{
"expected": "shorthand_property_declaration_template.js",
"generated": "shorthand_property_declaration.js"
}
],
"failureMessage": "Incorrect template"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Component, NgModule} from '@angular/core';

@Component({
template: `
<div (click)="_handleClick({a, b: 2, c})"></div>
`
})
export class MyComponent {
a = 1;
c = 3;
_handleClick(_value: any) {}
}

@NgModule({declarations: [MyComponent]})
export class MyModule {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵlistener("click", function MyComponent_Template_div_click_0_listener() {
return ctx._handleClick({
a: ctx.a,
b: 2,
c: ctx.c
});
});
}
}
16 changes: 14 additions & 2 deletions packages/compiler/src/expression_parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,11 +929,23 @@ export class _ParseAST {
if (!this.consumeOptionalCharacter(chars.$RBRACE)) {
this.rbracesExpected++;
do {
const keyStart = this.inputIndex;
const quoted = this.next.isString();
const key = this.expectIdentifierOrKeywordOrString();
keys.push({key, quoted});
this.expectCharacter(chars.$COLON);
values.push(this.parsePipe());

// Properties with quoted keys can't use the shorthand syntax.
if (quoted) {
this.expectCharacter(chars.$COLON);
values.push(this.parsePipe());
} else if (this.consumeOptionalCharacter(chars.$COLON)) {
values.push(this.parsePipe());
} else {
const span = this.span(keyStart);
const sourceSpan = this.sourceSpan(keyStart);
values.push(new PropertyRead(
span, sourceSpan, sourceSpan, new ImplicitReceiver(span, sourceSpan), key));
}
} while (this.consumeOptionalCharacter(chars.$COMMA));
this.rbracesExpected--;
this.expectCharacter(chars.$RBRACE);
Expand Down
17 changes: 17 additions & 0 deletions packages/compiler/test/expression_parser/parser_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ describe('parser', () => {
expectActionError('{1234:0}', 'expected identifier, keyword, or string');
expectActionError('{#myField:0}', 'expected identifier, keyword or string');
});

it('should parse property shorthand declarations', () => {
checkAction('{a, b, c}', '{a: a, b: b, c: c}');
checkAction('{a: 1, b}', '{a: 1, b: b}');
checkAction('{a, b: 1}', '{a: a, b: 1}');
checkAction('{a: 1, b, c: 2}', '{a: 1, b: b, c: 2}');
});

it('should not allow property shorthand declaration on quoted properties', () => {
expectActionError('{"a-b"}', 'expected : at column 7');
});

it('should not infer invalid identifiers as shorthand property declarations', () => {
expectActionError('{a.b}', 'expected } at column 3');
expectActionError('{a["b"]}', 'expected } at column 3');
expectActionError('{1234}', ' expected identifier, keyword, or string at column 2');
});
});

describe('member access', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/compiler/test/render3/r3_ast_absolute_span_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,15 @@ describe('expression AST absolute source spans', () => {
expect(spans).toContain(['nestedPlaceholder', new AbsoluteSourceSpan(89, 106)]);
});
});

describe('object literal', () => {
it('is correct for object literals with shorthand property declarations', () => {
const spans =
humanizeExpressionSource(parse('<div (click)="test({a: 1, b, c: 3, foo})"></div>').nodes);

expect(spans).toContain(['{a: 1, b: b, c: 3, foo: foo}', new AbsoluteSourceSpan(19, 39)]);
expect(spans).toContain(['b', new AbsoluteSourceSpan(26, 27)]);
expect(spans).toContain(['foo', new AbsoluteSourceSpan(35, 38)]);
});
});
});
Loading
0