From ec27bd4ed1cc086d76d8142fadf87354b38aa61a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 3 Apr 2021 18:10:31 +0200 Subject: [PATCH] feat(compiler): support nullish coalescing in templates (#41437) Adds support for nullish coalescing expressions inside of Angular templates (e.g. `{{ a ?? b ?? c}}`). Fixes #36528. PR Close #41437 --- .../linker/babel/src/ast/babel_ast_factory.ts | 1 + packages/compiler-cli/src/metadata/schema.ts | 2 +- .../ngtsc/translator/src/api/ast_factory.ts | 2 +- .../src/ngtsc/translator/src/translator.ts | 1 + .../translator/src/typescript_ast_factory.ts | 1 + .../src/ngtsc/typecheck/src/expression.ts | 1 + .../ngtsc/typecheck/test/diagnostics_spec.ts | 13 ++ .../typecheck/test/type_check_block_spec.ts | 7 + .../src/transformers/node_emitter.ts | 3 + .../nullish_coalescing/GOLDEN_PARTIAL.js | 164 ++++++++++++++++++ .../nullish_coalescing/TEST_CASES.json | 56 ++++++ .../nullish_coalescing_host.ts | 23 +++ .../nullish_coalescing_host_bindings.js | 13 ++ .../nullish_coalescing_interpolation.ts | 18 ++ ...llish_coalescing_interpolation_template.js | 19 ++ .../nullish_coalescing_property.ts | 18 ++ .../nullish_coalescing_property_template.js | 14 ++ packages/compiler/src/aot/static_reflector.ts | 2 + .../src/compiler_util/expression_converter.ts | 26 ++- .../compiler/src/expression_parser/lexer.ts | 13 +- .../compiler/src/expression_parser/parser.ts | 15 +- .../compiler/src/output/abstract_emitter.ts | 3 + packages/compiler/src/output/output_ast.ts | 6 +- .../compiler/src/output/output_interpreter.ts | 2 + .../test/expression_parser/lexer_spec.ts | 4 + .../test/expression_parser/parser_spec.ts | 2 + .../core/test/acceptance/integration_spec.ts | 61 +++++++ 27 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/GOLDEN_PARTIAL.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/TEST_CASES.json create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host_bindings.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation_template.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property_template.js diff --git a/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts index 70f4b408af..50c4c56e25 100644 --- a/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts +++ b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts @@ -39,6 +39,7 @@ export class BabelAstFactory implements AstFactory { switch (operator) { case '&&': case '||': + case '??': return t.logicalExpression(operator, leftOperand, rightOperand); default: return t.binaryExpression(operator, leftOperand, rightOperand); diff --git a/packages/compiler-cli/src/metadata/schema.ts b/packages/compiler-cli/src/metadata/schema.ts index 06683c10ec..c6413da7a7 100644 --- a/packages/compiler-cli/src/metadata/schema.ts +++ b/packages/compiler-cli/src/metadata/schema.ts @@ -145,7 +145,7 @@ export function isMetadataSymbolicExpression(value: any): value is MetadataSymbo export interface MetadataSymbolicBinaryExpression { __symbolic: 'binary'; operator: '&&'|'||'|'|'|'^'|'&'|'=='|'!='|'==='|'!=='|'<'|'>'|'<='|'>='|'instanceof'|'in'|'as'| - '<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**'; + '<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**'|'??'; left: MetadataValue; right: MetadataValue; } diff --git a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts index 4a5890756d..cc430f1f5f 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts @@ -245,7 +245,7 @@ export type UnaryOperator = '+'|'-'|'!'; * The binary operators supported by the `AstFactory`. */ export type BinaryOperator = - '&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+'; + '&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+'|'??'; /** * The original location of the start or end of a node created by the `AstFactory`. diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index f2d71023a6..1aea892ea3 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts @@ -34,6 +34,7 @@ const BINARY_OPERATORS = new Map([ [o.BinaryOperator.NotIdentical, '!=='], [o.BinaryOperator.Or, '||'], [o.BinaryOperator.Plus, '+'], + [o.BinaryOperator.NullishCoalesce, '??'], ]); export type RecordWrappedNodeExprFn = (expr: TExpression) => void; diff --git a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts index 6de1302c31..ebe0f3f0e3 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts @@ -47,6 +47,7 @@ const BINARY_OPERATORS: Record = { '!==': ts.SyntaxKind.ExclamationEqualsEqualsToken, '||': ts.SyntaxKind.BarBarToken, '+': ts.SyntaxKind.PlusToken, + '??': ts.SyntaxKind.QuestionQuestionToken, }; const VAR_TYPES: Record = { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index 1ecb5061b8..61a5535fe0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -41,6 +41,7 @@ const BINARY_OPS = new Map([ ['&&', ts.SyntaxKind.AmpersandAmpersandToken], ['&', ts.SyntaxKind.AmpersandToken], ['|', ts.SyntaxKind.BarToken], + ['??', ts.SyntaxKind.QuestionQuestionToken], ]); /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts index cd7e820cb6..718d791615 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts @@ -399,6 +399,19 @@ runInEachFileSystem(() => { expect(messages).toEqual([]); }); + + it('does not produce diagnostic for fallback value using nullish coalescing', () => { + const messages = diagnose(`
{{ greet(name ?? 'Frodo') }}
`, ` + export class TestComponent { + name: string | null; + + greet(name: string) { + return 'hello ' + name; + } + }`); + + expect(messages).toEqual([]); + }); }); it('computes line and column offsets', () => { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 9ccb53db84..b15735bb90 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -50,6 +50,13 @@ describe('type check blocks', () => { .toContain('(((ctx).a) ? ((ctx).b) : ((((ctx).c) ? ((ctx).d) : (((ctx).e)))))'); }); + it('should handle nullish coalescing operator', () => { + expect(tcb('{{ a ?? b }}')).toContain('((((ctx).a)) ?? (((ctx).b)))'); + expect(tcb('{{ a ?? b ?? c }}')).toContain('(((((ctx).a)) ?? (((ctx).b))) ?? (((ctx).c)))'); + expect(tcb('{{ (a ?? b) + (c ?? e) }}')) + .toContain('(((((ctx).a)) ?? (((ctx).b))) + ((((ctx).c)) ?? (((ctx).e))))'); + }); + it('should handle quote expressions as any type', () => { const TEMPLATE = ``; expect(tcb(TEMPLATE)).toContain('null as any'); diff --git a/packages/compiler-cli/src/transformers/node_emitter.ts b/packages/compiler-cli/src/transformers/node_emitter.ts index 2a1cc6fd78..6f08aa37b7 100644 --- a/packages/compiler-cli/src/transformers/node_emitter.ts +++ b/packages/compiler-cli/src/transformers/node_emitter.ts @@ -672,6 +672,9 @@ export class NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor { case BinaryOperator.Or: binaryOperator = ts.SyntaxKind.BarBarToken; break; + case BinaryOperator.NullishCoalesce: + binaryOperator = ts.SyntaxKind.QuestionQuestionToken; + break; case BinaryOperator.Plus: binaryOperator = ts.SyntaxKind.PlusToken; break; diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/GOLDEN_PARTIAL.js new file mode 100644 index 0000000000..5398bb0fea --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/GOLDEN_PARTIAL.js @@ -0,0 +1,164 @@ +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_interpolation.js + ****************************************************************************************************/ +import { Component, NgModule } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.firstName = null; + this.lastName = null; + this.lastNameFallback = 'Baggins'; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", ngImport: i0, template: ` +
Hello, {{ firstName ?? 'Frodo' }}!
+ Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }} + `, isInline: true }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{ + type: Component, + args: [{ + selector: 'my-app', + template: ` +
Hello, {{ firstName ?? 'Frodo' }}!
+ Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }} + ` + }] + }], null, null); })(); +export class MyModule { +} +MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); +MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] }); +MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{ + type: NgModule, + args: [{ declarations: [MyApp] }] + }], null, null); })(); + +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_interpolation.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + firstName: string | null; + lastName: string | null; + lastNameFallback: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class MyModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_property.js + ****************************************************************************************************/ +import { Component, NgModule } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.firstName = null; + this.lastName = null; + this.lastNameFallback = 'Baggins'; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", ngImport: i0, template: ` +
+ + `, isInline: true }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{ + type: Component, + args: [{ + selector: 'my-app', + template: ` +
+ + ` + }] + }], null, null); })(); +export class MyModule { +} +MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); +MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] }); +MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{ + type: NgModule, + args: [{ declarations: [MyApp] }] + }], null, null); })(); + +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_property.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + firstName: string | null; + lastName: string | null; + lastNameFallback: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class MyModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_host.js + ****************************************************************************************************/ +import { Component, NgModule } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.firstName = null; + this.lastName = null; + this.lastNameFallback = 'Baggins'; + } + logLastName(name) { + console.log(name); + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", host: { listeners: { "click": "logLastName(lastName ?? lastNameFallback ?? 'unknown')" }, properties: { "attr.first-name": "'Hello, ' + (firstName ?? 'Frodo') + '!'" } }, ngImport: i0, template: ``, isInline: true }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{ + type: Component, + args: [{ + selector: 'my-app', + host: { + '[attr.first-name]': `'Hello, ' + (firstName ?? 'Frodo') + '!'`, + '(click)': `logLastName(lastName ?? lastNameFallback ?? 'unknown')` + }, + template: `` + }] + }], null, null); })(); +export class MyModule { +} +MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); +MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] }); +MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule }); +(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{ + type: NgModule, + args: [{ declarations: [MyApp] }] + }], null, null); })(); + +/**************************************************************************************************** + * PARTIAL FILE: nullish_coalescing_host.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + firstName: string | null; + lastName: string | null; + lastNameFallback: string; + logLastName(name: string): void; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class MyModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/TEST_CASES.json new file mode 100644 index 0000000000..94a9e45c03 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/TEST_CASES.json @@ -0,0 +1,56 @@ +{ + "$schema": "../../test_case_schema.json", + "cases": [ + { + "description": "should handle nullish coalescing inside interpolations", + "inputFiles": [ + "nullish_coalescing_interpolation.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "nullish_coalescing_interpolation_template.js", + "generated": "nullish_coalescing_interpolation.js" + } + ], + "failureMessage": "Incorrect template" + } + ] + }, + { + "description": "should handle nullish coalescing inside property bindings", + "inputFiles": [ + "nullish_coalescing_property.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "nullish_coalescing_property_template.js", + "generated": "nullish_coalescing_property.js" + } + ], + "failureMessage": "Incorrect template" + } + ] + }, + { + "description": "should handle nullish coalescing inside host bindings", + "inputFiles": [ + "nullish_coalescing_host.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "nullish_coalescing_host_bindings.js", + "generated": "nullish_coalescing_host.js" + } + ], + "failureMessage": "Incorrect host bindings" + } + ] + } + ] +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host.ts new file mode 100644 index 0000000000..c7401612bc --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host.ts @@ -0,0 +1,23 @@ +import {Component, NgModule} from '@angular/core'; + +@Component({ + selector: 'my-app', + host: { + '[attr.first-name]': `'Hello, ' + (firstName ?? 'Frodo') + '!'`, + '(click)': `logLastName(lastName ?? lastNameFallback ?? 'unknown')` + }, + template: `` +}) +export class MyApp { + firstName: string|null = null; + lastName: string|null = null; + lastNameFallback = 'Baggins'; + + logLastName(name: string) { + console.log(name); + } +} + +@NgModule({declarations: [MyApp]}) +export class MyModule { +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host_bindings.js new file mode 100644 index 0000000000..ce0fbb747e --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_host_bindings.js @@ -0,0 +1,13 @@ +hostBindings: function MyApp_HostBindings(rf, ctx) { + if (rf & 1) { + i0.ɵɵlistener("click", function MyApp_click_HostBindingHandler() { + let tmp_b_0 = null; + let tmp_b_1 = null; + return ctx.logLastName((tmp_b_0 = (tmp_b_1 = ctx.lastName) !== null && tmp_b_1 !== undefined ? tmp_b_1 : ctx.lastNameFallback) !== null && tmp_b_0 !== undefined ? tmp_b_0 : "unknown"); + }); + } + if (rf & 2) { + let tmp_b_0 = null; + i0.ɵɵattribute("first-name", "Hello, " + ((tmp_b_0 = ctx.firstName) !== null && tmp_b_0 !== undefined ? tmp_b_0 : "Frodo") + "!"); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation.ts new file mode 100644 index 0000000000..1e0903b78a --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation.ts @@ -0,0 +1,18 @@ +import {Component, NgModule} from '@angular/core'; + +@Component({ + selector: 'my-app', + template: ` +
Hello, {{ firstName ?? 'Frodo' }}!
+ Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }} + ` +}) +export class MyApp { + firstName: string|null = null; + lastName: string|null = null; + lastNameFallback = 'Baggins'; +} + +@NgModule({declarations: [MyApp]}) +export class MyModule { +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation_template.js new file mode 100644 index 0000000000..af887ed333 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_interpolation_template.js @@ -0,0 +1,19 @@ +template: function MyApp_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "div"); + i0.ɵɵtext(1); + i0.ɵɵelementEnd(); + i0.ɵɵelementStart(2, "span"); + i0.ɵɵtext(3); + i0.ɵɵelementEnd(); + } + if (rf & 2) { + let tmp_0_0 = null; + let tmp_1_0 = null; + let tmp_1_1 = null; + i0.ɵɵadvance(1); + i0.ɵɵtextInterpolate1("Hello, ", (tmp_0_0 = ctx.firstName) !== null && tmp_0_0 !== undefined ? tmp_0_0 : "Frodo", "!"); + i0.ɵɵadvance(2); + i0.ɵɵtextInterpolate1("Your last name is ", (tmp_1_0 = (tmp_1_1 = ctx.lastName) !== null && tmp_1_1 !== undefined ? tmp_1_1 : ctx.lastNameFallback) !== null && tmp_1_0 !== undefined ? tmp_1_0 : "unknown", ""); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property.ts new file mode 100644 index 0000000000..76ed5869c2 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property.ts @@ -0,0 +1,18 @@ +import {Component, NgModule} from '@angular/core'; + +@Component({ + selector: 'my-app', + template: ` +
+ + ` +}) +export class MyApp { + firstName: string|null = null; + lastName: string|null = null; + lastNameFallback = 'Baggins'; +} + +@NgModule({declarations: [MyApp]}) +export class MyModule { +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property_template.js new file mode 100644 index 0000000000..297595f004 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/nullish_coalescing/nullish_coalescing_property_template.js @@ -0,0 +1,14 @@ +template: function MyApp_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelement(0, "div", 0); + i0.ɵɵelement(1, "span", 0); + } + if (rf & 2) { + let tmp_0_0 = null; + let tmp_1_0 = null; + let tmp_1_1 = null; + i0.ɵɵproperty("title", "Hello, " + ((tmp_0_0 = ctx.firstName) !== null && tmp_0_0 !== undefined ? tmp_0_0 : "Frodo") + "!"); + i0.ɵɵadvance(1); + i0.ɵɵproperty("title", (tmp_1_0 = (tmp_1_1 = "Your last name is " + ctx.lastName) !== null && tmp_1_1 !== undefined ? tmp_1_1 : ctx.lastNameFallback) !== null && tmp_1_0 !== undefined ? tmp_1_0 : "unknown"); + } +} diff --git a/packages/compiler/src/aot/static_reflector.ts b/packages/compiler/src/aot/static_reflector.ts index 9964474086..563cf3b224 100644 --- a/packages/compiler/src/aot/static_reflector.ts +++ b/packages/compiler/src/aot/static_reflector.ts @@ -672,6 +672,8 @@ export class StaticReflector implements CompileReflector { return left / right; case '%': return left % right; + case '??': + return left ?? right; } return null; case 'if': diff --git a/packages/compiler/src/compiler_util/expression_converter.ts b/packages/compiler/src/compiler_util/expression_converter.ts index ecda7aa218..d139217b1e 100644 --- a/packages/compiler/src/compiler_util/expression_converter.ts +++ b/packages/compiler/src/compiler_util/expression_converter.ts @@ -399,6 +399,8 @@ class _AstToIrVisitor implements cdAst.AstVisitor { case '>=': op = o.BinaryOperator.BiggerEquals; break; + case '??': + return this.convertNullishCoalesce(ast, mode); default: throw new Error(`Unsupported operation ${ast.operation}`); } @@ -683,7 +685,7 @@ class _AstToIrVisitor implements cdAst.AstVisitor { let guardedExpression = this._visit(leftMostSafe.receiver, _Mode.Expression); let temporary: o.ReadVarExpr = undefined!; - if (this.needsTemporary(leftMostSafe.receiver)) { + if (this.needsTemporaryInSafeAccess(leftMostSafe.receiver)) { // If the expression has method calls or pipes then we need to save the result into a // temporary variable to avoid calling stateful or impure code more than once. temporary = this.allocateTemporary(); @@ -728,6 +730,26 @@ class _AstToIrVisitor implements cdAst.AstVisitor { return convertToStatementIfNeeded(mode, condition.conditional(o.literal(null), access)); } + private convertNullishCoalesce(ast: cdAst.Binary, mode: _Mode): any { + // Allocate the temporary variable before visiting the LHS and RHS, because they + // may allocate temporary variables too and we don't want them to be reused. + const temporary = this.allocateTemporary(); + const left: o.Expression = this._visit(ast.left, _Mode.Expression); + const right: o.Expression = this._visit(ast.right, _Mode.Expression); + this.releaseTemporary(temporary); + + // Generate the following expression. It is identical to how TS + // transpiles binary expressions with a nullish coalescing operator. + // let temp; + // (temp = a) !== null && temp !== undefined ? temp : b; + return convertToStatementIfNeeded( + mode, + temporary.set(left) + .notIdentical(o.NULL_EXPR) + .and(temporary.notIdentical(o.literal(undefined))) + .conditional(temporary, right)); + } + // Given an expression of the form a?.b.c?.d.e then the left most safe node is // the (a?.b). The . and ?. are left associative thus can be rewritten as: // ((((a?.c).b).c)?.d).e. This returns the most deeply nested safe read or @@ -812,7 +834,7 @@ class _AstToIrVisitor implements cdAst.AstVisitor { // Returns true of the AST includes a method or a pipe indicating that, if the // expression is used as the target of a safe property or method access then // the expression should be stored into a temporary variable. - private needsTemporary(ast: cdAst.AST): boolean { + private needsTemporaryInSafeAccess(ast: cdAst.AST): boolean { const visit = (visitor: cdAst.AstVisitor, ast: cdAst.AST): boolean => { return ast && (this._nodeMap.get(ast) || ast).visit(visitor); }; diff --git a/packages/compiler/src/expression_parser/lexer.ts b/packages/compiler/src/expression_parser/lexer.ts index cc1614218b..7346dd8609 100644 --- a/packages/compiler/src/expression_parser/lexer.ts +++ b/packages/compiler/src/expression_parser/lexer.ts @@ -212,7 +212,7 @@ class _Scanner { case chars.$CARET: return this.scanOperator(start, String.fromCharCode(peek)); case chars.$QUESTION: - return this.scanComplexOperator(start, '?', chars.$PERIOD, '.'); + return this.scanQuestion(start); case chars.$LT: case chars.$GT: return this.scanComplexOperator(start, String.fromCharCode(peek), chars.$EQ, '='); @@ -348,6 +348,17 @@ class _Scanner { return newStringToken(start, this.index, buffer + last); } + scanQuestion(start: number): Token { + this.advance(); + let str: string = '?'; + // Either `a ?? b` or 'a?.b'. + if (this.peek === chars.$QUESTION || this.peek === chars.$PERIOD) { + str += this.peek === chars.$PERIOD ? '.' : '?'; + this.advance(); + } + return newOperatorToken(start, this.index, str); + } + error(message: string, offset: number): Token { const position: number = this.index + offset; return newErrorToken( diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index a20d1057ae..4e4c2acaa8 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -669,14 +669,25 @@ export class _ParseAST { parseLogicalAnd(): AST { // '&&' const start = this.inputIndex; - let result = this.parseEquality(); + let result = this.parseNullishCoalescing(); while (this.consumeOptionalOperator('&&')) { - const right = this.parseEquality(); + const right = this.parseNullishCoalescing(); result = new Binary(this.span(start), this.sourceSpan(start), '&&', result, right); } return result; } + parseNullishCoalescing(): AST { + // '??' + const start = this.inputIndex; + let result = this.parseEquality(); + while (this.consumeOptionalOperator('??')) { + const right = this.parseEquality(); + result = new Binary(this.span(start), this.sourceSpan(start), '??', result, right); + } + return result; + } + parseEquality(): AST { // '==','!=','===','!==' const start = this.inputIndex; diff --git a/packages/compiler/src/output/abstract_emitter.ts b/packages/compiler/src/output/abstract_emitter.ts index 388cb3e8aa..727c276c73 100644 --- a/packages/compiler/src/output/abstract_emitter.ts +++ b/packages/compiler/src/output/abstract_emitter.ts @@ -510,6 +510,9 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex case o.BinaryOperator.BiggerEquals: opStr = '>='; break; + case o.BinaryOperator.NullishCoalesce: + opStr = '??'; + break; default: throw new Error(`Unknown operator ${ast.operator}`); } diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index bf14072310..4faa216c8f 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -115,7 +115,8 @@ export enum BinaryOperator { Lower, LowerEquals, Bigger, - BiggerEquals + BiggerEquals, + NullishCoalesce, } export function nullSafeIsEquivalent( @@ -254,6 +255,9 @@ export abstract class Expression { cast(type: Type, sourceSpan?: ParseSourceSpan|null): Expression { return new CastExpr(this, type, sourceSpan); } + nullishCoalesce(rhs: Expression, sourceSpan?: ParseSourceSpan|null): BinaryOperatorExpr { + return new BinaryOperatorExpr(BinaryOperator.NullishCoalesce, this, rhs, null, sourceSpan); + } toStmt(): Statement { return new ExpressionStatement(this, null); diff --git a/packages/compiler/src/output/output_interpreter.ts b/packages/compiler/src/output/output_interpreter.ts index 22d7dbec83..cfcbc43c91 100644 --- a/packages/compiler/src/output/output_interpreter.ts +++ b/packages/compiler/src/output/output_interpreter.ts @@ -332,6 +332,8 @@ class StatementInterpreter implements o.StatementVisitor, o.ExpressionVisitor { return lhs() > rhs(); case o.BinaryOperator.BiggerEquals: return lhs() >= rhs(); + case o.BinaryOperator.NullishCoalesce: + return lhs() ?? rhs(); default: throw new Error(`Unknown operator ${ast.operator}`); } diff --git a/packages/compiler/test/expression_parser/lexer_spec.ts b/packages/compiler/test/expression_parser/lexer_spec.ts index 33814492bf..664529f9ef 100644 --- a/packages/compiler/test/expression_parser/lexer_spec.ts +++ b/packages/compiler/test/expression_parser/lexer_spec.ts @@ -255,6 +255,10 @@ function expectErrorToken(token: Token, index: any, end: number, message: string it('should tokenize ?. as operator', () => { expectOperatorToken(lex('?.')[0], 0, 2, '?.'); }); + + it('should tokenize ?? as operator', () => { + expectOperatorToken(lex('??')[0], 0, 2, '??'); + }); }); }); } diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index 5c4ab2c876..f266911a2b 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -81,6 +81,8 @@ describe('parser', () => { it('should parse expressions', () => { checkAction('true && true'); checkAction('true || false'); + checkAction('null ?? 0'); + checkAction('null ?? undefined ?? 0'); }); it('should parse grouped expressions', () => { diff --git a/packages/core/test/acceptance/integration_spec.ts b/packages/core/test/acceptance/integration_spec.ts index bc4e911b9e..8d45e0541c 100644 --- a/packages/core/test/acceptance/integration_spec.ts +++ b/packages/core/test/acceptance/integration_spec.ts @@ -1929,6 +1929,67 @@ describe('acceptance integration tests', () => { expect(() => fixture.detectChanges()).toThrowError('this error is expected'); }); + it('should handle nullish coalescing inside templates', () => { + @Component({ + template: ` + + Hello, {{ firstName ?? 'Frodo' }}! + You are a Balrog: {{ falsyValue ?? true }} + + ` + }) + class App { + firstName: string|null = null; + lastName: string|null = null; + lastNameFallback = 'Baggins'; + falsyValue = false; + } + + TestBed.configureTestingModule({declarations: [App]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const content = fixture.nativeElement.innerHTML; + + expect(content).toContain('Hello, Frodo!'); + expect(content).toContain('You are a Balrog: false'); + expect(content).toContain(``); + }); + + it('should handle nullish coalescing inside host bindings', () => { + const logs: string[] = []; + + @Directive({ + selector: '[some-dir]', + host: { + '[attr.first-name]': `'Hello, ' + (firstName ?? 'Frodo') + '!'`, + '(click)': `logLastName(lastName ?? lastNameFallback ?? 'unknown')` + } + }) + class Dir { + firstName: string|null = null; + lastName: string|null = null; + lastNameFallback = 'Baggins'; + + logLastName(name: string) { + logs.push(name); + } + } + + @Component({template: ``}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + button.click(); + fixture.detectChanges(); + + expect(button.getAttribute('first-name')).toBe('Hello, Frodo!'); + expect(logs).toEqual(['Baggins']); + }); + describe('tView.firstUpdatePass', () => { function isFirstUpdatePass() { const lView = getLView();