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
This commit is contained in:
Kristiyan Kostadinov 2021-04-03 18:10:31 +02:00 committed by atscott
parent d641542587
commit ec27bd4ed1
27 changed files with 482 additions and 8 deletions

View File

@ -39,6 +39,7 @@ export class BabelAstFactory implements AstFactory<t.Statement, t.Expression> {
switch (operator) {
case '&&':
case '||':
case '??':
return t.logicalExpression(operator, leftOperand, rightOperand);
default:
return t.binaryExpression(operator, leftOperand, rightOperand);

View File

@ -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;
}

View File

@ -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`.

View File

@ -34,6 +34,7 @@ const BINARY_OPERATORS = new Map<o.BinaryOperator, BinaryOperator>([
[o.BinaryOperator.NotIdentical, '!=='],
[o.BinaryOperator.Or, '||'],
[o.BinaryOperator.Plus, '+'],
[o.BinaryOperator.NullishCoalesce, '??'],
]);
export type RecordWrappedNodeExprFn<TExpression> = (expr: TExpression) => void;

View File

@ -47,6 +47,7 @@ const BINARY_OPERATORS: Record<BinaryOperator, ts.BinaryOperator> = {
'!==': ts.SyntaxKind.ExclamationEqualsEqualsToken,
'||': ts.SyntaxKind.BarBarToken,
'+': ts.SyntaxKind.PlusToken,
'??': ts.SyntaxKind.QuestionQuestionToken,
};
const VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> = {

View File

@ -41,6 +41,7 @@ const BINARY_OPS = new Map<string, ts.BinaryOperator>([
['&&', ts.SyntaxKind.AmpersandAmpersandToken],
['&', ts.SyntaxKind.AmpersandToken],
['|', ts.SyntaxKind.BarToken],
['??', ts.SyntaxKind.QuestionQuestionToken],
]);
/**

View File

@ -399,6 +399,19 @@ runInEachFileSystem(() => {
expect(messages).toEqual([]);
});
it('does not produce diagnostic for fallback value using nullish coalescing', () => {
const messages = diagnose(`<div>{{ greet(name ?? 'Frodo') }}</div>`, `
export class TestComponent {
name: string | null;
greet(name: string) {
return 'hello ' + name;
}
}`);
expect(messages).toEqual([]);
});
});
it('computes line and column offsets', () => {

View File

@ -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 = `<span [quote]="sql:expression"></span>`;
expect(tcb(TEMPLATE)).toContain('null as any');

View File

@ -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;

View File

@ -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: `
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
`, isInline: true });
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{
type: Component,
args: [{
selector: 'my-app',
template: `
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
`
}]
}], 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<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}
/****************************************************************************************************
* 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: `
<div [title]="'Hello, ' + (firstName ?? 'Frodo') + '!'"></div>
<span [title]="'Your last name is ' + lastName ?? lastNameFallback ?? 'unknown'"></span>
`, isInline: true });
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{
type: Component,
args: [{
selector: 'my-app',
template: `
<div [title]="'Hello, ' + (firstName ?? 'Frodo') + '!'"></div>
<span [title]="'Your last name is ' + lastName ?? lastNameFallback ?? 'unknown'"></span>
`
}]
}], 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<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}
/****************************************************************************************************
* 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<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

View File

@ -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"
}
]
}
]
}

View File

@ -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 {
}

View File

@ -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") + "!");
}
}

View File

@ -0,0 +1,18 @@
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'my-app',
template: `
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
`
})
export class MyApp {
firstName: string|null = null;
lastName: string|null = null;
lastNameFallback = 'Baggins';
}
@NgModule({declarations: [MyApp]})
export class MyModule {
}

View File

@ -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", "");
}
}

View File

@ -0,0 +1,18 @@
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'my-app',
template: `
<div [title]="'Hello, ' + (firstName ?? 'Frodo') + '!'"></div>
<span [title]="'Your last name is ' + lastName ?? lastNameFallback ?? 'unknown'"></span>
`
})
export class MyApp {
firstName: string|null = null;
lastName: string|null = null;
lastNameFallback = 'Baggins';
}
@NgModule({declarations: [MyApp]})
export class MyModule {
}

View File

@ -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");
}
}

View File

@ -672,6 +672,8 @@ export class StaticReflector implements CompileReflector {
return left / right;
case '%':
return left % right;
case '??':
return left ?? right;
}
return null;
case 'if':

View File

@ -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);
};

View File

@ -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(

View File

@ -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;

View File

@ -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}`);
}

View File

@ -115,7 +115,8 @@ export enum BinaryOperator {
Lower,
LowerEquals,
Bigger,
BiggerEquals
BiggerEquals,
NullishCoalesce,
}
export function nullSafeIsEquivalent<T extends {isEquivalent(other: T): boolean}>(
@ -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);

View File

@ -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}`);
}

View File

@ -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, '??');
});
});
});
}

View File

@ -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', () => {

View File

@ -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: `
<span [title]="'Your last name is ' + (lastName ?? lastNameFallback ?? 'unknown')">
Hello, {{ firstName ?? 'Frodo' }}!
You are a Balrog: {{ falsyValue ?? true }}
</span>
`
})
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(`<span title="Your last name is Baggins">`);
});
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: `<button some-dir>Click me</button>`})
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();