Skip to content

Commit ec27bd4

Browse files
crisbetoatscott
authored andcommitted
feat(compiler): support nullish coalescing in templates (angular#41437)
Adds support for nullish coalescing expressions inside of Angular templates (e.g. `{{ a ?? b ?? c}}`). Fixes angular#36528. PR Close angular#41437
1 parent d641542 commit ec27bd4

27 files changed

+482
-8
lines changed

packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class BabelAstFactory implements AstFactory<t.Statement, t.Expression> {
3939
switch (operator) {
4040
case '&&':
4141
case '||':
42+
case '??':
4243
return t.logicalExpression(operator, leftOperand, rightOperand);
4344
default:
4445
return t.binaryExpression(operator, leftOperand, rightOperand);

packages/compiler-cli/src/metadata/schema.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export function isMetadataSymbolicExpression(value: any): value is MetadataSymbo
145145
export interface MetadataSymbolicBinaryExpression {
146146
__symbolic: 'binary';
147147
operator: '&&'|'||'|'|'|'^'|'&'|'=='|'!='|'==='|'!=='|'<'|'>'|'<='|'>='|'instanceof'|'in'|'as'|
148-
'<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**';
148+
'<<'|'>>'|'>>>'|'+'|'-'|'*'|'/'|'%'|'**'|'??';
149149
left: MetadataValue;
150150
right: MetadataValue;
151151
}

packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export type UnaryOperator = '+'|'-'|'!';
245245
* The binary operators supported by the `AstFactory`.
246246
*/
247247
export type BinaryOperator =
248-
'&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+';
248+
'&&'|'>'|'>='|'&'|'/'|'=='|'==='|'<'|'<='|'-'|'%'|'*'|'!='|'!=='|'||'|'+'|'??';
249249

250250
/**
251251
* The original location of the start or end of a node created by the `AstFactory`.

packages/compiler-cli/src/ngtsc/translator/src/translator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const BINARY_OPERATORS = new Map<o.BinaryOperator, BinaryOperator>([
3434
[o.BinaryOperator.NotIdentical, '!=='],
3535
[o.BinaryOperator.Or, '||'],
3636
[o.BinaryOperator.Plus, '+'],
37+
[o.BinaryOperator.NullishCoalesce, '??'],
3738
]);
3839

3940
export type RecordWrappedNodeExprFn<TExpression> = (expr: TExpression) => void;

packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const BINARY_OPERATORS: Record<BinaryOperator, ts.BinaryOperator> = {
4747
'!==': ts.SyntaxKind.ExclamationEqualsEqualsToken,
4848
'||': ts.SyntaxKind.BarBarToken,
4949
'+': ts.SyntaxKind.PlusToken,
50+
'??': ts.SyntaxKind.QuestionQuestionToken,
5051
};
5152

5253
const VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> = {

packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const BINARY_OPS = new Map<string, ts.BinaryOperator>([
4141
['&&', ts.SyntaxKind.AmpersandAmpersandToken],
4242
['&', ts.SyntaxKind.AmpersandToken],
4343
['|', ts.SyntaxKind.BarToken],
44+
['??', ts.SyntaxKind.QuestionQuestionToken],
4445
]);
4546

4647
/**

packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts

+13
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,19 @@ runInEachFileSystem(() => {
399399

400400
expect(messages).toEqual([]);
401401
});
402+
403+
it('does not produce diagnostic for fallback value using nullish coalescing', () => {
404+
const messages = diagnose(`<div>{{ greet(name ?? 'Frodo') }}</div>`, `
405+
export class TestComponent {
406+
name: string | null;
407+
408+
greet(name: string) {
409+
return 'hello ' + name;
410+
}
411+
}`);
412+
413+
expect(messages).toEqual([]);
414+
});
402415
});
403416

404417
it('computes line and column offsets', () => {

packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts

+7
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ describe('type check blocks', () => {
5050
.toContain('(((ctx).a) ? ((ctx).b) : ((((ctx).c) ? ((ctx).d) : (((ctx).e)))))');
5151
});
5252

53+
it('should handle nullish coalescing operator', () => {
54+
expect(tcb('{{ a ?? b }}')).toContain('((((ctx).a)) ?? (((ctx).b)))');
55+
expect(tcb('{{ a ?? b ?? c }}')).toContain('(((((ctx).a)) ?? (((ctx).b))) ?? (((ctx).c)))');
56+
expect(tcb('{{ (a ?? b) + (c ?? e) }}'))
57+
.toContain('(((((ctx).a)) ?? (((ctx).b))) + ((((ctx).c)) ?? (((ctx).e))))');
58+
});
59+
5360
it('should handle quote expressions as any type', () => {
5461
const TEMPLATE = `<span [quote]="sql:expression"></span>`;
5562
expect(tcb(TEMPLATE)).toContain('null as any');

packages/compiler-cli/src/transformers/node_emitter.ts

+3
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,9 @@ export class NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor {
672672
case BinaryOperator.Or:
673673
binaryOperator = ts.SyntaxKind.BarBarToken;
674674
break;
675+
case BinaryOperator.NullishCoalesce:
676+
binaryOperator = ts.SyntaxKind.QuestionQuestionToken;
677+
break;
675678
case BinaryOperator.Plus:
676679
binaryOperator = ts.SyntaxKind.PlusToken;
677680
break;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/****************************************************************************************************
2+
* PARTIAL FILE: nullish_coalescing_interpolation.js
3+
****************************************************************************************************/
4+
import { Component, NgModule } from '@angular/core';
5+
import * as i0 from "@angular/core";
6+
export class MyApp {
7+
constructor() {
8+
this.firstName = null;
9+
this.lastName = null;
10+
this.lastNameFallback = 'Baggins';
11+
}
12+
}
13+
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
14+
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", ngImport: i0, template: `
15+
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
16+
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
17+
`, isInline: true });
18+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{
19+
type: Component,
20+
args: [{
21+
selector: 'my-app',
22+
template: `
23+
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
24+
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
25+
`
26+
}]
27+
}], null, null); })();
28+
export class MyModule {
29+
}
30+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
31+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] });
32+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
33+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{
34+
type: NgModule,
35+
args: [{ declarations: [MyApp] }]
36+
}], null, null); })();
37+
38+
/****************************************************************************************************
39+
* PARTIAL FILE: nullish_coalescing_interpolation.d.ts
40+
****************************************************************************************************/
41+
import * as i0 from "@angular/core";
42+
export declare class MyApp {
43+
firstName: string | null;
44+
lastName: string | null;
45+
lastNameFallback: string;
46+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
47+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
48+
}
49+
export declare class MyModule {
50+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
51+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
52+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
53+
}
54+
55+
/****************************************************************************************************
56+
* PARTIAL FILE: nullish_coalescing_property.js
57+
****************************************************************************************************/
58+
import { Component, NgModule } from '@angular/core';
59+
import * as i0 from "@angular/core";
60+
export class MyApp {
61+
constructor() {
62+
this.firstName = null;
63+
this.lastName = null;
64+
this.lastNameFallback = 'Baggins';
65+
}
66+
}
67+
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
68+
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", ngImport: i0, template: `
69+
<div [title]="'Hello, ' + (firstName ?? 'Frodo') + '!'"></div>
70+
<span [title]="'Your last name is ' + lastName ?? lastNameFallback ?? 'unknown'"></span>
71+
`, isInline: true });
72+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{
73+
type: Component,
74+
args: [{
75+
selector: 'my-app',
76+
template: `
77+
<div [title]="'Hello, ' + (firstName ?? 'Frodo') + '!'"></div>
78+
<span [title]="'Your last name is ' + lastName ?? lastNameFallback ?? 'unknown'"></span>
79+
`
80+
}]
81+
}], null, null); })();
82+
export class MyModule {
83+
}
84+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
85+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] });
86+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
87+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{
88+
type: NgModule,
89+
args: [{ declarations: [MyApp] }]
90+
}], null, null); })();
91+
92+
/****************************************************************************************************
93+
* PARTIAL FILE: nullish_coalescing_property.d.ts
94+
****************************************************************************************************/
95+
import * as i0 from "@angular/core";
96+
export declare class MyApp {
97+
firstName: string | null;
98+
lastName: string | null;
99+
lastNameFallback: string;
100+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
101+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
102+
}
103+
export declare class MyModule {
104+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
105+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
106+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
107+
}
108+
109+
/****************************************************************************************************
110+
* PARTIAL FILE: nullish_coalescing_host.js
111+
****************************************************************************************************/
112+
import { Component, NgModule } from '@angular/core';
113+
import * as i0 from "@angular/core";
114+
export class MyApp {
115+
constructor() {
116+
this.firstName = null;
117+
this.lastName = null;
118+
this.lastNameFallback = 'Baggins';
119+
}
120+
logLastName(name) {
121+
console.log(name);
122+
}
123+
}
124+
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
125+
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 });
126+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyApp, [{
127+
type: Component,
128+
args: [{
129+
selector: 'my-app',
130+
host: {
131+
'[attr.first-name]': `'Hello, ' + (firstName ?? 'Frodo') + '!'`,
132+
'(click)': `logLastName(lastName ?? lastNameFallback ?? 'unknown')`
133+
},
134+
template: ``
135+
}]
136+
}], null, null); })();
137+
export class MyModule {
138+
}
139+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
140+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] });
141+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
142+
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MyModule, [{
143+
type: NgModule,
144+
args: [{ declarations: [MyApp] }]
145+
}], null, null); })();
146+
147+
/****************************************************************************************************
148+
* PARTIAL FILE: nullish_coalescing_host.d.ts
149+
****************************************************************************************************/
150+
import * as i0 from "@angular/core";
151+
export declare class MyApp {
152+
firstName: string | null;
153+
lastName: string | null;
154+
lastNameFallback: string;
155+
logLastName(name: string): void;
156+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
157+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never>;
158+
}
159+
export declare class MyModule {
160+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
161+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
162+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
163+
}
164+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"$schema": "../../test_case_schema.json",
3+
"cases": [
4+
{
5+
"description": "should handle nullish coalescing inside interpolations",
6+
"inputFiles": [
7+
"nullish_coalescing_interpolation.ts"
8+
],
9+
"expectations": [
10+
{
11+
"files": [
12+
{
13+
"expected": "nullish_coalescing_interpolation_template.js",
14+
"generated": "nullish_coalescing_interpolation.js"
15+
}
16+
],
17+
"failureMessage": "Incorrect template"
18+
}
19+
]
20+
},
21+
{
22+
"description": "should handle nullish coalescing inside property bindings",
23+
"inputFiles": [
24+
"nullish_coalescing_property.ts"
25+
],
26+
"expectations": [
27+
{
28+
"files": [
29+
{
30+
"expected": "nullish_coalescing_property_template.js",
31+
"generated": "nullish_coalescing_property.js"
32+
}
33+
],
34+
"failureMessage": "Incorrect template"
35+
}
36+
]
37+
},
38+
{
39+
"description": "should handle nullish coalescing inside host bindings",
40+
"inputFiles": [
41+
"nullish_coalescing_host.ts"
42+
],
43+
"expectations": [
44+
{
45+
"files": [
46+
{
47+
"expected": "nullish_coalescing_host_bindings.js",
48+
"generated": "nullish_coalescing_host.js"
49+
}
50+
],
51+
"failureMessage": "Incorrect host bindings"
52+
}
53+
]
54+
}
55+
]
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {Component, NgModule} from '@angular/core';
2+
3+
@Component({
4+
selector: 'my-app',
5+
host: {
6+
'[attr.first-name]': `'Hello, ' + (firstName ?? 'Frodo') + '!'`,
7+
'(click)': `logLastName(lastName ?? lastNameFallback ?? 'unknown')`
8+
},
9+
template: ``
10+
})
11+
export class MyApp {
12+
firstName: string|null = null;
13+
lastName: string|null = null;
14+
lastNameFallback = 'Baggins';
15+
16+
logLastName(name: string) {
17+
console.log(name);
18+
}
19+
}
20+
21+
@NgModule({declarations: [MyApp]})
22+
export class MyModule {
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
hostBindings: function MyApp_HostBindings(rf, ctx) {
2+
if (rf & 1) {
3+
i0.ɵɵlistener("click", function MyApp_click_HostBindingHandler() {
4+
let tmp_b_0 = null;
5+
let tmp_b_1 = null;
6+
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");
7+
});
8+
}
9+
if (rf & 2) {
10+
let tmp_b_0 = null;
11+
i0.ɵɵattribute("first-name", "Hello, " + ((tmp_b_0 = ctx.firstName) !== null && tmp_b_0 !== undefined ? tmp_b_0 : "Frodo") + "!");
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {Component, NgModule} from '@angular/core';
2+
3+
@Component({
4+
selector: 'my-app',
5+
template: `
6+
<div>Hello, {{ firstName ?? 'Frodo' }}!</div>
7+
<span>Your last name is {{ lastName ?? lastNameFallback ?? 'unknown' }}</span>
8+
`
9+
})
10+
export class MyApp {
11+
firstName: string|null = null;
12+
lastName: string|null = null;
13+
lastNameFallback = 'Baggins';
14+
}
15+
16+
@NgModule({declarations: [MyApp]})
17+
export class MyModule {
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
template: function MyApp_Template(rf, ctx) {
2+
if (rf & 1) {
3+
i0.ɵɵelementStart(0, "div");
4+
i0.ɵɵtext(1);
5+
i0.ɵɵelementEnd();
6+
i0.ɵɵelementStart(2, "span");
7+
i0.ɵɵtext(3);
8+
i0.ɵɵelementEnd();
9+
}
10+
if (rf & 2) {
11+
let tmp_0_0 = null;
12+
let tmp_1_0 = null;
13+
let tmp_1_1 = null;
14+
i0.ɵɵadvance(1);
15+
i0.ɵɵtextInterpolate1("Hello, ", (tmp_0_0 = ctx.firstName) !== null && tmp_0_0 !== undefined ? tmp_0_0 : "Frodo", "!");
16+
i0.ɵɵadvance(2);
17+
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", "");
18+
}
19+
}

0 commit comments

Comments
 (0)