fix(ivy): generate a better error for template var writes (#34339)
In Ivy it's illegal for a template to write to a template variable. So the template: ```html <ng-template let-somevar> <button (click)="somevar = 3">Set var to 3</button> </ng-template> ``` is erroneous and previously would fail to compile with an assertion error from the `TemplateDefinitionBuilder`. This error wasn't particularly user- friendly, though, as it lacked the context of which template or where the error occurred. In this commit, a new check in template type-checking is added which detects such erroneous writes and produces a true diagnostic with the appropriate context information. Closes #33674 PR Close #34339
This commit is contained in:
		
							parent
							
								
									74edde0a94
								
							
						
					
					
						commit
						6ba5fdc208
					
				| @ -95,6 +95,20 @@ export enum ErrorCode { | |||||||
|    */ |    */ | ||||||
|   MISSING_PIPE = 8004, |   MISSING_PIPE = 8004, | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * The left-hand side of an assignment expression was a template variable. Effectively, the | ||||||
|  |    * template looked like: | ||||||
|  |    * | ||||||
|  |    * ``` | ||||||
|  |    * <ng-template let-something> | ||||||
|  |    *   <button (click)="something = ...">...</button> | ||||||
|  |    * </ng-template> | ||||||
|  |    * ``` | ||||||
|  |    * | ||||||
|  |    * Template variables are read-only. | ||||||
|  |    */ | ||||||
|  |   WRITE_TO_READ_ONLY_VARIABLE = 8005, | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * An injectable already has a `ɵprov` property. |    * An injectable already has a `ɵprov` property. | ||||||
|    */ |    */ | ||||||
|  | |||||||
| @ -158,8 +158,22 @@ export function translateDiagnostic( | |||||||
|  */ |  */ | ||||||
| export function makeTemplateDiagnostic( | export function makeTemplateDiagnostic( | ||||||
|     mapping: TemplateSourceMapping, span: ParseSourceSpan, category: ts.DiagnosticCategory, |     mapping: TemplateSourceMapping, span: ParseSourceSpan, category: ts.DiagnosticCategory, | ||||||
|     code: number, messageText: string | ts.DiagnosticMessageChain): ts.Diagnostic { |     code: number, messageText: string | ts.DiagnosticMessageChain, relatedMessage?: { | ||||||
|  |       text: string, | ||||||
|  |       span: ParseSourceSpan, | ||||||
|  |     }): ts.Diagnostic { | ||||||
|   if (mapping.type === 'direct') { |   if (mapping.type === 'direct') { | ||||||
|  |     let relatedInformation: ts.DiagnosticRelatedInformation[]|undefined = undefined; | ||||||
|  |     if (relatedMessage !== undefined) { | ||||||
|  |       relatedInformation = [{ | ||||||
|  |         category: ts.DiagnosticCategory.Message, | ||||||
|  |         code: 0, | ||||||
|  |         file: mapping.node.getSourceFile(), | ||||||
|  |         start: relatedMessage.span.start.offset, | ||||||
|  |         length: relatedMessage.span.end.offset - relatedMessage.span.start.offset, | ||||||
|  |         messageText: relatedMessage.text, | ||||||
|  |       }]; | ||||||
|  |     } | ||||||
|     // For direct mappings, the error is shown inline as ngtsc was able to pinpoint a string
 |     // For direct mappings, the error is shown inline as ngtsc was able to pinpoint a string
 | ||||||
|     // constant within the `@Component` decorator for the template. This allows us to map the error
 |     // constant within the `@Component` decorator for the template. This allows us to map the error
 | ||||||
|     // directly into the bytes of the source file.
 |     // directly into the bytes of the source file.
 | ||||||
| @ -170,7 +184,7 @@ export function makeTemplateDiagnostic( | |||||||
|       messageText, |       messageText, | ||||||
|       file: mapping.node.getSourceFile(), |       file: mapping.node.getSourceFile(), | ||||||
|       start: span.start.offset, |       start: span.start.offset, | ||||||
|       length: span.end.offset - span.start.offset, |       length: span.end.offset - span.start.offset, relatedInformation, | ||||||
|     }; |     }; | ||||||
|   } else if (mapping.type === 'indirect' || mapping.type === 'external') { |   } else if (mapping.type === 'indirect' || mapping.type === 'external') { | ||||||
|     // For indirect mappings (template was declared inline, but ngtsc couldn't map it directly
 |     // For indirect mappings (template was declared inline, but ngtsc couldn't map it directly
 | ||||||
| @ -189,6 +203,29 @@ export function makeTemplateDiagnostic( | |||||||
|     const sf = ts.createSourceFile( |     const sf = ts.createSourceFile( | ||||||
|         fileName, mapping.template, ts.ScriptTarget.Latest, false, ts.ScriptKind.JSX); |         fileName, mapping.template, ts.ScriptTarget.Latest, false, ts.ScriptKind.JSX); | ||||||
| 
 | 
 | ||||||
|  |     let relatedInformation: ts.DiagnosticRelatedInformation[] = []; | ||||||
|  |     if (relatedMessage !== undefined) { | ||||||
|  |       relatedInformation.push({ | ||||||
|  |         category: ts.DiagnosticCategory.Message, | ||||||
|  |         code: 0, | ||||||
|  |         file: sf, | ||||||
|  |         start: relatedMessage.span.start.offset, | ||||||
|  |         length: relatedMessage.span.end.offset - relatedMessage.span.start.offset, | ||||||
|  |         messageText: relatedMessage.text, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     relatedInformation.push({ | ||||||
|  |       category: ts.DiagnosticCategory.Message, | ||||||
|  |       code: 0, | ||||||
|  |       file: componentSf, | ||||||
|  |       // mapping.node represents either the 'template' or 'templateUrl' expression. getStart()
 | ||||||
|  |       // and getEnd() are used because they don't include surrounding whitespace.
 | ||||||
|  |       start: mapping.node.getStart(), | ||||||
|  |       length: mapping.node.getEnd() - mapping.node.getStart(), | ||||||
|  |       messageText: `Error occurs in the template of component ${componentName}.`, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|       source: 'ngtsc', |       source: 'ngtsc', | ||||||
|       category, |       category, | ||||||
| @ -198,16 +235,7 @@ export function makeTemplateDiagnostic( | |||||||
|       start: span.start.offset, |       start: span.start.offset, | ||||||
|       length: span.end.offset - span.start.offset, |       length: span.end.offset - span.start.offset, | ||||||
|       // Show a secondary message indicating the component whose template contains the error.
 |       // Show a secondary message indicating the component whose template contains the error.
 | ||||||
|       relatedInformation: [{ |       relatedInformation, | ||||||
|         category: ts.DiagnosticCategory.Message, |  | ||||||
|         code: 0, |  | ||||||
|         file: componentSf, |  | ||||||
|         // mapping.node represents either the 'template' or 'templateUrl' expression. getStart()
 |  | ||||||
|         // and getEnd() are used because they don't include surrounding whitespace.
 |  | ||||||
|         start: mapping.node.getStart(), |  | ||||||
|         length: mapping.node.getEnd() - mapping.node.getStart(), |  | ||||||
|         messageText: `Error occurs in the template of component ${componentName}.`, |  | ||||||
|       }], |  | ||||||
|     }; |     }; | ||||||
|   } else { |   } else { | ||||||
|     throw new Error(`Unexpected source mapping type: ${(mapping as {type: string}).type}`); |     throw new Error(`Unexpected source mapping type: ${(mapping as {type: string}).type}`); | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ | |||||||
|  * found in the LICENSE file at https://angular.io/license
 |  * found in the LICENSE file at https://angular.io/license
 | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import {AbsoluteSourceSpan, BindingPipe, TmplAstReference} from '@angular/compiler'; | import {AbsoluteSourceSpan, BindingPipe, PropertyWrite, TmplAstReference, TmplAstVariable} from '@angular/compiler'; | ||||||
| import * as ts from 'typescript'; | import * as ts from 'typescript'; | ||||||
| 
 | 
 | ||||||
| import {ErrorCode, ngErrorCode} from '../../diagnostics'; | import {ErrorCode, ngErrorCode} from '../../diagnostics'; | ||||||
| @ -49,6 +49,10 @@ export interface OutOfBandDiagnosticRecorder { | |||||||
|    * plus span of the larger expression context. |    * plus span of the larger expression context. | ||||||
|    */ |    */ | ||||||
|   missingPipe(templateId: string, ast: BindingPipe, sourceSpan: AbsoluteSourceSpan): void; |   missingPipe(templateId: string, ast: BindingPipe, sourceSpan: AbsoluteSourceSpan): void; | ||||||
|  | 
 | ||||||
|  |   illegalAssignmentToTemplateVar( | ||||||
|  |       templateId: string, assignment: PropertyWrite, assignmentSpan: AbsoluteSourceSpan, | ||||||
|  |       target: TmplAstVariable): void; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder { | export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder { | ||||||
| @ -82,4 +86,24 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor | |||||||
|         mapping, sourceSpan, ts.DiagnosticCategory.Error, ngErrorCode(ErrorCode.MISSING_PIPE), |         mapping, sourceSpan, ts.DiagnosticCategory.Error, ngErrorCode(ErrorCode.MISSING_PIPE), | ||||||
|         errorMsg)); |         errorMsg)); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   illegalAssignmentToTemplateVar( | ||||||
|  |       templateId: string, assignment: PropertyWrite, assignmentSpan: AbsoluteSourceSpan, | ||||||
|  |       target: TmplAstVariable): void { | ||||||
|  |     const mapping = this.resolver.getSourceMapping(templateId); | ||||||
|  |     const errorMsg = | ||||||
|  |         `Cannot use variable '${assignment.name}' as the left-hand side of an assignment expression. Template variables are read-only.`; | ||||||
|  | 
 | ||||||
|  |     const location = absoluteSourceSpanToSourceLocation(templateId, assignmentSpan); | ||||||
|  |     const sourceSpan = this.resolver.sourceLocationToSpan(location); | ||||||
|  |     if (sourceSpan === null) { | ||||||
|  |       throw new Error(`Assertion failure: no SourceLocation found for property binding.`); | ||||||
|  |     } | ||||||
|  |     this._diagnostics.push(makeTemplateDiagnostic( | ||||||
|  |         mapping, sourceSpan, ts.DiagnosticCategory.Error, | ||||||
|  |         ngErrorCode(ErrorCode.WRITE_TO_READ_ONLY_VARIABLE), errorMsg, { | ||||||
|  |           text: `The variable ${assignment.name} is declared here.`, | ||||||
|  |           span: target.valueSpan || target.sourceSpan, | ||||||
|  |         })); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -0,0 +1,44 @@ | |||||||
|  | /** | ||||||
|  |  * @license | ||||||
|  |  * Copyright Google Inc. All Rights Reserved. | ||||||
|  |  * | ||||||
|  |  * Use of this source code is governed by an MIT-style license that can be | ||||||
|  |  * found in the LICENSE file at https://angular.io/license
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import {AST, BoundTarget, ImplicitReceiver, ParseSourceSpan, PropertyWrite, RecursiveAstVisitor, TmplAstVariable} from '@angular/compiler'; | ||||||
|  | 
 | ||||||
|  | import {toAbsoluteSpan} from './diagnostics'; | ||||||
|  | import {OutOfBandDiagnosticRecorder} from './oob'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Visits a template and records any semantic errors within its expressions. | ||||||
|  |  */ | ||||||
|  | export class ExpressionSemanticVisitor extends RecursiveAstVisitor { | ||||||
|  |   constructor( | ||||||
|  |       private templateId: string, private boundTarget: BoundTarget<any>, | ||||||
|  |       private oob: OutOfBandDiagnosticRecorder, private sourceSpan: ParseSourceSpan) { | ||||||
|  |     super(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   visitPropertyWrite(ast: PropertyWrite, context: any): void { | ||||||
|  |     super.visitPropertyWrite(ast, context); | ||||||
|  | 
 | ||||||
|  |     if (!(ast.receiver instanceof ImplicitReceiver)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const target = this.boundTarget.getExpressionTarget(ast); | ||||||
|  |     if (target instanceof TmplAstVariable) { | ||||||
|  |       // Template variables are read-only.
 | ||||||
|  |       const astSpan = toAbsoluteSpan(ast.span, this.sourceSpan); | ||||||
|  |       this.oob.illegalAssignmentToTemplateVar(this.templateId, ast, astSpan, target); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static visit( | ||||||
|  |       ast: AST, sourceSpan: ParseSourceSpan, id: string, boundTarget: BoundTarget<any>, | ||||||
|  |       oob: OutOfBandDiagnosticRecorder): void { | ||||||
|  |     ast.visit(new ExpressionSemanticVisitor(id, boundTarget, oob, sourceSpan)); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -6,7 +6,7 @@ | |||||||
|  * found in the LICENSE file at https://angular.io/license
 |  * found in the LICENSE file at https://angular.io/license
 | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import {AST, BindingPipe, BindingType, BoundTarget, DYNAMIC_TYPE, ImplicitReceiver, MethodCall, ParseSourceSpan, ParseSpan, ParsedEventType, PropertyRead, SchemaMetadata, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; | import {AST, BindingPipe, BindingType, BoundTarget, DYNAMIC_TYPE, ImplicitReceiver, MethodCall, ParseSourceSpan, ParseSpan, ParsedEventType, PropertyRead, PropertyWrite, SchemaMetadata, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; | ||||||
| import * as ts from 'typescript'; | import * as ts from 'typescript'; | ||||||
| 
 | 
 | ||||||
| import {Reference} from '../../imports'; | import {Reference} from '../../imports'; | ||||||
| @ -18,6 +18,7 @@ import {DomSchemaChecker} from './dom'; | |||||||
| import {Environment} from './environment'; | import {Environment} from './environment'; | ||||||
| import {NULL_AS_ANY, astToTypescript} from './expression'; | import {NULL_AS_ANY, astToTypescript} from './expression'; | ||||||
| import {OutOfBandDiagnosticRecorder} from './oob'; | import {OutOfBandDiagnosticRecorder} from './oob'; | ||||||
|  | import {ExpressionSemanticVisitor} from './template_semantics'; | ||||||
| import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util'; | import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util'; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -491,6 +492,10 @@ class TcbDirectiveOutputsOp extends TcbOp { | |||||||
|         const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any); |         const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any); | ||||||
|         this.scope.addStatement(ts.createExpressionStatement(handler)); |         this.scope.addStatement(ts.createExpressionStatement(handler)); | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|  |       ExpressionSemanticVisitor.visit( | ||||||
|  |           output.handler, output.handlerSpan, this.tcb.id, this.tcb.boundTarget, | ||||||
|  |           this.tcb.oobRecorder); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return null; |     return null; | ||||||
| @ -549,6 +554,10 @@ class TcbUnclaimedOutputsOp extends TcbOp { | |||||||
|         const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any); |         const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any); | ||||||
|         this.scope.addStatement(ts.createExpressionStatement(handler)); |         this.scope.addStatement(ts.createExpressionStatement(handler)); | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|  |       ExpressionSemanticVisitor.visit( | ||||||
|  |           output.handler, output.handlerSpan, this.tcb.id, this.tcb.boundTarget, | ||||||
|  |           this.tcb.oobRecorder); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return null; |     return null; | ||||||
| @ -972,6 +981,16 @@ class TcbExpressionTranslator { | |||||||
|       // returned here to let it fall through resolution so it will be caught when the
 |       // returned here to let it fall through resolution so it will be caught when the
 | ||||||
|       // `ImplicitReceiver` is resolved in the branch below.
 |       // `ImplicitReceiver` is resolved in the branch below.
 | ||||||
|       return this.resolveTarget(ast); |       return this.resolveTarget(ast); | ||||||
|  |     } else if (ast instanceof PropertyWrite && ast.receiver instanceof ImplicitReceiver) { | ||||||
|  |       const target = this.resolveTarget(ast); | ||||||
|  |       if (target === null) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const expr = this.translate(ast.value); | ||||||
|  |       const result = ts.createParen(ts.createBinary(target, ts.SyntaxKind.EqualsToken, expr)); | ||||||
|  |       addParseSpanInfo(result, toAbsoluteSpan(ast.span, this.sourceSpan)); | ||||||
|  |       return result; | ||||||
|     } else if (ast instanceof ImplicitReceiver) { |     } else if (ast instanceof ImplicitReceiver) { | ||||||
|       // AST instances representing variables and references look very similar to property reads
 |       // AST instances representing variables and references look very similar to property reads
 | ||||||
|       // or method calls from the component context: both have the shape
 |       // or method calls from the component context: both have the shape
 | ||||||
|  | |||||||
| @ -389,4 +389,5 @@ export class NoopOobRecorder implements OutOfBandDiagnosticRecorder { | |||||||
|   get diagnostics(): ReadonlyArray<ts.Diagnostic> { return []; } |   get diagnostics(): ReadonlyArray<ts.Diagnostic> { return []; } | ||||||
|   missingReferenceTarget(): void {} |   missingReferenceTarget(): void {} | ||||||
|   missingPipe(): void {} |   missingPipe(): void {} | ||||||
|  |   illegalAssignmentToTemplateVar(): void {} | ||||||
| } | } | ||||||
|  | |||||||
| @ -280,6 +280,12 @@ describe('type check blocks', () => { | |||||||
|           '_t1.addEventListener("event", ($event): any => (ctx).foo(($event as any)));'); |           '_t1.addEventListener("event", ($event): any => (ctx).foo(($event as any)));'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('should detect writes to template variables', () => { | ||||||
|  |       const TEMPLATE = `<ng-template let-v><div (event)="v = 3"></div></ng-template>`; | ||||||
|  |       const block = tcb(TEMPLATE); | ||||||
|  |       expect(block).toContain('_t3.addEventListener("event", ($event): any => (_t2 = 3))'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('config', () => { |   describe('config', () => { | ||||||
|  | |||||||
| @ -1121,6 +1121,34 @@ export declare class AnimationEvent { | |||||||
|       expect(getSourceCodeForDiagnostic(diags[2])).toEqual('[fromChild]="4"'); |       expect(getSourceCodeForDiagnostic(diags[2])).toEqual('[fromChild]="4"'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('should detect an illegal write to a template variable', () => { | ||||||
|  |       env.write('test.ts', ` | ||||||
|  |         import {Component, NgModule} from '@angular/core'; | ||||||
|  |         import {CommonModule} from '@angular/common'; | ||||||
|  | 
 | ||||||
|  |         @Component({ | ||||||
|  |           selector: 'test', | ||||||
|  |           template: \` | ||||||
|  |             <div *ngIf="x as y"> | ||||||
|  |               <button (click)="y = !y">Toggle</button> | ||||||
|  |             </div> | ||||||
|  |           \`,
 | ||||||
|  |         }) | ||||||
|  |         export class TestCmp { | ||||||
|  |           x!: boolean; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @NgModule({ | ||||||
|  |           declarations: [TestCmp], | ||||||
|  |           imports: [CommonModule], | ||||||
|  |         }) | ||||||
|  |         export class Module {} | ||||||
|  |       `);
 | ||||||
|  |       const diags = env.driveDiagnostics(); | ||||||
|  |       expect(diags.length).toEqual(1); | ||||||
|  |       expect(getSourceCodeForDiagnostic(diags[0])).toEqual('y = !y'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     describe('input coercion', () => { |     describe('input coercion', () => { | ||||||
|       beforeEach(() => { |       beforeEach(() => { | ||||||
|         env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); |         env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user