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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user