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