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) {
|
switch (operator) {
|
||||||
case '&&':
|
case '&&':
|
||||||
case '||':
|
case '||':
|
||||||
|
case '??':
|
||||||
return t.logicalExpression(operator, leftOperand, rightOperand);
|
return t.logicalExpression(operator, leftOperand, rightOperand);
|
||||||
default:
|
default:
|
||||||
return t.binaryExpression(operator, leftOperand, rightOperand);
|
return t.binaryExpression(operator, leftOperand, rightOperand);
|
||||||
|
@ -145,7 +145,7 @@ export function isMetadataSymbolicExpression(value: any): value is MetadataSymbo
|
|||||||
export interface MetadataSymbolicBinaryExpression {
|
export interface MetadataSymbolicBinaryExpression {
|
||||||
__symbolic: 'binary';
|
__symbolic: 'binary';
|
||||||
operator: '&&'|'||'|'|'|'^'|'&'|'=='|'!='|'==='|'!=='|'<'|'>'|'<='|'>='|'instanceof'|'in'|'as'|
|
operator: '&&'|'||'|'|'|'^'|'&'|'=='|'!='|'==='|'!=='|'<'|'>'|'<='|'>='|'instanceof'|'in'|'as'|
|
||||||
'<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**';
|
'<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**'|'??';
|
||||||
left: MetadataValue;
|
left: MetadataValue;
|
||||||
right: MetadataValue;
|
right: MetadataValue;
|
||||||
}
|
}
|
||||||
|
@ -245,7 +245,7 @@ export type UnaryOperator = '+'|'-'|'!';
|
|||||||
* The binary operators supported by the `AstFactory`.
|
* The binary operators supported by the `AstFactory`.
|
||||||
*/
|
*/
|
||||||
export type BinaryOperator =
|
export type BinaryOperator =
|
||||||
'&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+';
|
'&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+'|'??';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The original location of the start or end of a node created by the `AstFactory`.
|
* 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.NotIdentical, '!=='],
|
||||||
[o.BinaryOperator.Or, '||'],
|
[o.BinaryOperator.Or, '||'],
|
||||||
[o.BinaryOperator.Plus, '+'],
|
[o.BinaryOperator.Plus, '+'],
|
||||||
|
[o.BinaryOperator.NullishCoalesce, '??'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type RecordWrappedNodeExprFn<TExpression> = (expr: TExpression) => void;
|
export type RecordWrappedNodeExprFn<TExpression> = (expr: TExpression) => void;
|
||||||
|
@ -47,6 +47,7 @@ const BINARY_OPERATORS: Record<BinaryOperator, ts.BinaryOperator> = {
|
|||||||
'!==': ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
'!==': ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
||||||
'||': ts.SyntaxKind.BarBarToken,
|
'||': ts.SyntaxKind.BarBarToken,
|
||||||
'+': ts.SyntaxKind.PlusToken,
|
'+': ts.SyntaxKind.PlusToken,
|
||||||
|
'??': ts.SyntaxKind.QuestionQuestionToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> = {
|
const VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> = {
|
||||||
|
@ -41,6 +41,7 @@ const BINARY_OPS = new Map<string, ts.BinaryOperator>([
|
|||||||
['&&', ts.SyntaxKind.AmpersandAmpersandToken],
|
['&&', ts.SyntaxKind.AmpersandAmpersandToken],
|
||||||
['&', ts.SyntaxKind.AmpersandToken],
|
['&', ts.SyntaxKind.AmpersandToken],
|
||||||
['|', ts.SyntaxKind.BarToken],
|
['|', ts.SyntaxKind.BarToken],
|
||||||
|
['??', ts.SyntaxKind.QuestionQuestionToken],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -399,6 +399,19 @@ runInEachFileSystem(() => {
|
|||||||
|
|
||||||
expect(messages).toEqual([]);
|
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', () => {
|
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)))))');
|
.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', () => {
|
it('should handle quote expressions as any type', () => {
|
||||||
const TEMPLATE = `<span [quote]="sql:expression"></span>`;
|
const TEMPLATE = `<span [quote]="sql:expression"></span>`;
|
||||||
expect(tcb(TEMPLATE)).toContain('null as any');
|
expect(tcb(TEMPLATE)).toContain('null as any');
|
||||||
|
@ -672,6 +672,9 @@ export class NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor {
|
|||||||
case BinaryOperator.Or:
|
case BinaryOperator.Or:
|
||||||
binaryOperator = ts.SyntaxKind.BarBarToken;
|
binaryOperator = ts.SyntaxKind.BarBarToken;
|
||||||
break;
|
break;
|
||||||
|
case BinaryOperator.NullishCoalesce:
|
||||||
|
binaryOperator = ts.SyntaxKind.QuestionQuestionToken;
|
||||||
|
break;
|
||||||
case BinaryOperator.Plus:
|
case BinaryOperator.Plus:
|
||||||
binaryOperator = ts.SyntaxKind.PlusToken;
|
binaryOperator = ts.SyntaxKind.PlusToken;
|
||||||
break;
|
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;
|
return left / right;
|
||||||
case '%':
|
case '%':
|
||||||
return left % right;
|
return left % right;
|
||||||
|
case '??':
|
||||||
|
return left ?? right;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
case 'if':
|
case 'if':
|
||||||
|
@ -399,6 +399,8 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
|
|||||||
case '>=':
|
case '>=':
|
||||||
op = o.BinaryOperator.BiggerEquals;
|
op = o.BinaryOperator.BiggerEquals;
|
||||||
break;
|
break;
|
||||||
|
case '??':
|
||||||
|
return this.convertNullishCoalesce(ast, mode);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported operation ${ast.operation}`);
|
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 guardedExpression = this._visit(leftMostSafe.receiver, _Mode.Expression);
|
||||||
let temporary: o.ReadVarExpr = undefined!;
|
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
|
// 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 variable to avoid calling stateful or impure code more than once.
|
||||||
temporary = this.allocateTemporary();
|
temporary = this.allocateTemporary();
|
||||||
@ -728,6 +730,26 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
|
|||||||
return convertToStatementIfNeeded(mode, condition.conditional(o.literal(null), access));
|
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
|
// 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:
|
// 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
|
// ((((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
|
// 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
|
// expression is used as the target of a safe property or method access then
|
||||||
// the expression should be stored into a temporary variable.
|
// 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 => {
|
const visit = (visitor: cdAst.AstVisitor, ast: cdAst.AST): boolean => {
|
||||||
return ast && (this._nodeMap.get(ast) || ast).visit(visitor);
|
return ast && (this._nodeMap.get(ast) || ast).visit(visitor);
|
||||||
};
|
};
|
||||||
|
@ -212,7 +212,7 @@ class _Scanner {
|
|||||||
case chars.$CARET:
|
case chars.$CARET:
|
||||||
return this.scanOperator(start, String.fromCharCode(peek));
|
return this.scanOperator(start, String.fromCharCode(peek));
|
||||||
case chars.$QUESTION:
|
case chars.$QUESTION:
|
||||||
return this.scanComplexOperator(start, '?', chars.$PERIOD, '.');
|
return this.scanQuestion(start);
|
||||||
case chars.$LT:
|
case chars.$LT:
|
||||||
case chars.$GT:
|
case chars.$GT:
|
||||||
return this.scanComplexOperator(start, String.fromCharCode(peek), chars.$EQ, '=');
|
return this.scanComplexOperator(start, String.fromCharCode(peek), chars.$EQ, '=');
|
||||||
@ -348,6 +348,17 @@ class _Scanner {
|
|||||||
return newStringToken(start, this.index, buffer + last);
|
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 {
|
error(message: string, offset: number): Token {
|
||||||
const position: number = this.index + offset;
|
const position: number = this.index + offset;
|
||||||
return newErrorToken(
|
return newErrorToken(
|
||||||
|
@ -669,14 +669,25 @@ export class _ParseAST {
|
|||||||
parseLogicalAnd(): AST {
|
parseLogicalAnd(): AST {
|
||||||
// '&&'
|
// '&&'
|
||||||
const start = this.inputIndex;
|
const start = this.inputIndex;
|
||||||
let result = this.parseEquality();
|
let result = this.parseNullishCoalescing();
|
||||||
while (this.consumeOptionalOperator('&&')) {
|
while (this.consumeOptionalOperator('&&')) {
|
||||||
const right = this.parseEquality();
|
const right = this.parseNullishCoalescing();
|
||||||
result = new Binary(this.span(start), this.sourceSpan(start), '&&', result, right);
|
result = new Binary(this.span(start), this.sourceSpan(start), '&&', result, right);
|
||||||
}
|
}
|
||||||
return result;
|
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 {
|
parseEquality(): AST {
|
||||||
// '==','!=','===','!=='
|
// '==','!=','===','!=='
|
||||||
const start = this.inputIndex;
|
const start = this.inputIndex;
|
||||||
|
@ -510,6 +510,9 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex
|
|||||||
case o.BinaryOperator.BiggerEquals:
|
case o.BinaryOperator.BiggerEquals:
|
||||||
opStr = '>=';
|
opStr = '>=';
|
||||||
break;
|
break;
|
||||||
|
case o.BinaryOperator.NullishCoalesce:
|
||||||
|
opStr = '??';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown operator ${ast.operator}`);
|
throw new Error(`Unknown operator ${ast.operator}`);
|
||||||
}
|
}
|
||||||
|
@ -115,7 +115,8 @@ export enum BinaryOperator {
|
|||||||
Lower,
|
Lower,
|
||||||
LowerEquals,
|
LowerEquals,
|
||||||
Bigger,
|
Bigger,
|
||||||
BiggerEquals
|
BiggerEquals,
|
||||||
|
NullishCoalesce,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nullSafeIsEquivalent<T extends {isEquivalent(other: T): boolean}>(
|
export function nullSafeIsEquivalent<T extends {isEquivalent(other: T): boolean}>(
|
||||||
@ -254,6 +255,9 @@ export abstract class Expression {
|
|||||||
cast(type: Type, sourceSpan?: ParseSourceSpan|null): Expression {
|
cast(type: Type, sourceSpan?: ParseSourceSpan|null): Expression {
|
||||||
return new CastExpr(this, type, sourceSpan);
|
return new CastExpr(this, type, sourceSpan);
|
||||||
}
|
}
|
||||||
|
nullishCoalesce(rhs: Expression, sourceSpan?: ParseSourceSpan|null): BinaryOperatorExpr {
|
||||||
|
return new BinaryOperatorExpr(BinaryOperator.NullishCoalesce, this, rhs, null, sourceSpan);
|
||||||
|
}
|
||||||
|
|
||||||
toStmt(): Statement {
|
toStmt(): Statement {
|
||||||
return new ExpressionStatement(this, null);
|
return new ExpressionStatement(this, null);
|
||||||
|
@ -332,6 +332,8 @@ class StatementInterpreter implements o.StatementVisitor, o.ExpressionVisitor {
|
|||||||
return lhs() > rhs();
|
return lhs() > rhs();
|
||||||
case o.BinaryOperator.BiggerEquals:
|
case o.BinaryOperator.BiggerEquals:
|
||||||
return lhs() >= rhs();
|
return lhs() >= rhs();
|
||||||
|
case o.BinaryOperator.NullishCoalesce:
|
||||||
|
return lhs() ?? rhs();
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown operator ${ast.operator}`);
|
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', () => {
|
it('should tokenize ?. as operator', () => {
|
||||||
expectOperatorToken(lex('?.')[0], 0, 2, '?.');
|
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', () => {
|
it('should parse expressions', () => {
|
||||||
checkAction('true && true');
|
checkAction('true && true');
|
||||||
checkAction('true || false');
|
checkAction('true || false');
|
||||||
|
checkAction('null ?? 0');
|
||||||
|
checkAction('null ?? undefined ?? 0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse grouped expressions', () => {
|
it('should parse grouped expressions', () => {
|
||||||
|
@ -1929,6 +1929,67 @@ describe('acceptance integration tests', () => {
|
|||||||
expect(() => fixture.detectChanges()).toThrowError('this error is expected');
|
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', () => {
|
describe('tView.firstUpdatePass', () => {
|
||||||
function isFirstUpdatePass() {
|
function isFirstUpdatePass() {
|
||||||
const lView = getLView();
|
const lView = getLView();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user