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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 13 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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;
Lines changed: 164 additions & 0 deletions
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+

0 commit comments

Comments
 (0)