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:
parent
d641542587
commit
ec27bd4ed1
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -41,6 +41,7 @@ const BINARY_OPS = new Map<string, ts.BinaryOperator>([
|
|||
['&&', ts.SyntaxKind.AmpersandAmpersandToken],
|
||||
['&', ts.SyntaxKind.AmpersandToken],
|
||||
['|', ts.SyntaxKind.BarToken],
|
||||
['??', ts.SyntaxKind.QuestionQuestionToken],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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") + "!");
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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", "");
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -672,6 +672,8 @@ export class StaticReflector implements CompileReflector {
|
|||
return left / right;
|
||||
case '%':
|
||||
return left % right;
|
||||
case '??':
|
||||
return left ?? right;
|
||||
}
|
||||
return null;
|
||||
case 'if':
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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, '??');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in New Issue