diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/code.ts index cbad3b5f84b8d..592c12059c9fa 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/code.ts @@ -84,6 +84,16 @@ export enum ErrorCode { * An element's attribute name failed validation against the DOM schema. */ SCHEMA_INVALID_ATTRIBUTE = 8002, + + /** + * No matching directive was found for a `#ref="target"` expression. + */ + MISSING_REFERENCE_TARGET = 8003, + + /** + * No matching pipe was found for a + */ + MISSING_PIPE = 8004, } export function ngErrorCode(code: ErrorCode): number { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts index d7c281983a410..92aa179984c18 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts @@ -19,6 +19,7 @@ import {shouldReportDiagnostic, translateDiagnostic} from './diagnostics'; import {DomSchemaChecker, RegistryDomSchemaChecker} from './dom'; import {Environment} from './environment'; import {TypeCheckProgramHost} from './host'; +import {OutOfBandDiagnosticRecorder, OutOfBandDiagnosticRecorderImpl} from './oob'; import {TcbSourceManager} from './source'; import {generateTypeCheckBlock, requiresInlineTypeCheckBlock} from './type_check_block'; import {TypeCheckFile} from './type_check_file'; @@ -58,6 +59,8 @@ export class TypeCheckContext { private domSchemaChecker = new RegistryDomSchemaChecker(this.sourceManager); + private oobRecorder = new OutOfBandDiagnosticRecorderImpl(this.sourceManager); + /** * Record a template for the given component `node`, with a `SelectorMatcher` for directive * matching. @@ -102,7 +105,8 @@ export class TypeCheckContext { this.addInlineTypeCheckBlock(ref, tcbMetadata); } else { // The class can be type-checked externally as normal. - this.typeCheckFile.addTypeCheckBlock(ref, tcbMetadata, this.domSchemaChecker); + this.typeCheckFile.addTypeCheckBlock( + ref, tcbMetadata, this.domSchemaChecker, this.oobRecorder); } } @@ -219,6 +223,7 @@ export class TypeCheckContext { } diagnostics.push(...this.domSchemaChecker.diagnostics); + diagnostics.push(...this.oobRecorder.diagnostics); return { diagnostics, @@ -234,7 +239,7 @@ export class TypeCheckContext { this.opMap.set(sf, []); } const ops = this.opMap.get(sf) !; - ops.push(new TcbOp(ref, tcbMeta, this.config, this.domSchemaChecker)); + ops.push(new TcbOp(ref, tcbMeta, this.config, this.domSchemaChecker, this.oobRecorder)); } } @@ -266,7 +271,8 @@ class TcbOp implements Op { constructor( readonly ref: Reference>, readonly meta: TypeCheckBlockMetadata, readonly config: TypeCheckingConfig, - readonly domSchemaChecker: DomSchemaChecker) {} + readonly domSchemaChecker: DomSchemaChecker, + readonly oobRecorder: OutOfBandDiagnosticRecorder) {} /** * Type check blocks are inserted immediately after the end of the component class. @@ -277,7 +283,8 @@ class TcbOp implements Op { string { const env = new Environment(this.config, im, refEmitter, sf); const fnName = ts.createIdentifier(`_tcb_${this.ref.node.pos}`); - const fn = generateTypeCheckBlock(env, this.ref, fnName, this.meta, this.domSchemaChecker); + const fn = generateTypeCheckBlock( + env, this.ref, fnName, this.meta, this.domSchemaChecker, this.oobRecorder); return printer.printNode(ts.EmitHint.Unspecified, fn, sf); } } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts index dae298b33ff26..6f761c843c57d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts @@ -5,7 +5,7 @@ * 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 {ParseSourceSpan, ParseSpan, Position} from '@angular/compiler'; +import {AbsoluteSourceSpan, ParseSourceSpan, ParseSpan, Position} from '@angular/compiler'; import * as ts from 'typescript'; import {getTokenAtPosition} from '../../util/src/typescript'; @@ -55,6 +55,11 @@ export function toAbsoluteSpan(span: ParseSpan, sourceSpan: ParseSourceSpan): Ab return {start: span.start + offset, end: span.end + offset}; } +export function absoluteSourceSpanToSourceLocation( + id: string, span: AbsoluteSourceSpan): SourceLocation { + return {id, ...span}; +} + /** * Wraps the node in parenthesis such that inserted span comments become attached to the proper * node. This is an alias for `ts.createParen` with the benefit that it signifies that the diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts new file mode 100644 index 0000000000000..4204f75b01b4d --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts @@ -0,0 +1,85 @@ +/** + * @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 {AbsoluteSourceSpan, BindingPipe, TmplAstReference} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {ErrorCode, ngErrorCode} from '../../diagnostics'; + +import {TcbSourceResolver, absoluteSourceSpanToSourceLocation, makeTemplateDiagnostic} from './diagnostics'; + + + +/** + * Collects `ts.Diagnostic`s on problems which occur in the template which aren't directly sourced + * from Type Check Blocks. + * + * During the creation of a Type Check Block, the template is traversed and the + * `OutOfBandDiagnosticRecorder` is called to record cases when a correct interpretation for the + * template cannot be found. These operations create `ts.Diagnostic`s which are stored by the + * recorder for later display. + */ +export interface OutOfBandDiagnosticRecorder { + readonly diagnostics: ReadonlyArray; + + /** + * Reports a `#ref="target"` expression in the template for which a target directive could not be + * found. + * + * @param templateId the template type-checking ID of the template which contains the broken + * reference. + * @param ref the `TmplAstReference` which could not be matched to a directive. + */ + missingReferenceTarget(templateId: string, ref: TmplAstReference): void; + + /** + * Reports usage of a `| pipe` expression in the template for which the named pipe could not be + * found. + * + * @param templateId the template type-checking ID of the template which contains the unknown + * pipe. + * @param ast the `BindingPipe` invocation of the pipe which could not be found. + * @param sourceSpan the `AbsoluteSourceSpan` of the pipe invocation (ideally, this should be the + * source span of the pipe's name). This depends on the source span of the `BindingPipe` itself + * plus span of the larger expression context. + */ + missingPipe(templateId: string, ast: BindingPipe, sourceSpan: AbsoluteSourceSpan): void; +} + +export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder { + private _diagnostics: ts.Diagnostic[] = []; + + constructor(private resolver: TcbSourceResolver) {} + + get diagnostics(): ReadonlyArray { return this._diagnostics; } + + missingReferenceTarget(templateId: string, ref: TmplAstReference): void { + const mapping = this.resolver.getSourceMapping(templateId); + const value = ref.value.trim(); + + const errorMsg = `No directive found with exportAs '${value}'.`; + this._diagnostics.push(makeTemplateDiagnostic( + mapping, ref.valueSpan || ref.sourceSpan, ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.MISSING_REFERENCE_TARGET), errorMsg)); + } + + missingPipe(templateId: string, ast: BindingPipe, absSpan: AbsoluteSourceSpan): void { + const mapping = this.resolver.getSourceMapping(templateId); + const errorMsg = `No pipe found with name '${ast.name}'.`; + + const location = absoluteSourceSpanToSourceLocation(templateId, absSpan); + const sourceSpan = this.resolver.sourceLocationToSpan(location); + if (sourceSpan === null) { + throw new Error( + `Assertion failure: no SourceLocation found for usage of pipe '${ast.name}'.`); + } + this._diagnostics.push(makeTemplateDiagnostic( + mapping, sourceSpan, ts.DiagnosticCategory.Error, ngErrorCode(ErrorCode.MISSING_PIPE), + errorMsg)); + } +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 384dfa1d8ff47..705de13bf6e79 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -17,6 +17,7 @@ import {addParseSpanInfo, addSourceId, toAbsoluteSpan, wrapForDiagnostics} from import {DomSchemaChecker} from './dom'; import {Environment} from './environment'; import {NULL_AS_ANY, astToTypescript} from './expression'; +import {OutOfBandDiagnosticRecorder} from './oob'; import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util'; @@ -28,15 +29,27 @@ import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsC * When passed through TypeScript's TypeChecker, type errors that arise within the type check block * function indicate issues in the template itself. * - * @param node the TypeScript node for the component class. + * As a side effect of generating a TCB for the component, `ts.Diagnostic`s may also be produced + * directly for issues within the template which are identified during generation. These issues are + * recorded in either the `domSchemaChecker` (which checks usage of DOM elements and bindings) as + * well as the `oobRecorder` (which records errors when the type-checking code generator is unable + * to sufficiently understand a template). + * + * @param env an `Environment` into which type-checking code will be generated. + * @param ref a `Reference` to the component class which should be type-checked. + * @param name a `ts.Identifier` to use for the generated `ts.FunctionDeclaration`. * @param meta metadata about the component's template and the function being generated. - * @param importManager an `ImportManager` for the file into which the TCB will be written. + * @param domSchemaChecker used to check and record errors regarding improper usage of DOM elements + * and bindings. + * @param oobRecorder used to record errors regarding template elements which could not be correctly + * translated into types during TCB generation. */ export function generateTypeCheckBlock( env: Environment, ref: Reference>, name: ts.Identifier, - meta: TypeCheckBlockMetadata, domSchemaChecker: DomSchemaChecker): ts.FunctionDeclaration { - const tcb = - new Context(env, domSchemaChecker, meta.id, meta.boundTarget, meta.pipes, meta.schemas); + meta: TypeCheckBlockMetadata, domSchemaChecker: DomSchemaChecker, + oobRecorder: OutOfBandDiagnosticRecorder): ts.FunctionDeclaration { + const tcb = new Context( + env, domSchemaChecker, oobRecorder, meta.id, meta.boundTarget, meta.pipes, meta.schemas); const scope = Scope.forNodes(tcb, null, tcb.boundTarget.target.template !); const ctxRawType = env.referenceType(ref); if (!ts.isTypeReferenceNode(ctxRawType)) { @@ -562,7 +575,8 @@ export class Context { private nextId = 1; constructor( - readonly env: Environment, readonly domSchemaChecker: DomSchemaChecker, readonly id: string, + readonly env: Environment, readonly domSchemaChecker: DomSchemaChecker, + readonly oobRecorder: OutOfBandDiagnosticRecorder, readonly id: string, readonly boundTarget: BoundTarget, private pipes: Map>>, readonly schemas: SchemaMetadata[]) {} @@ -575,9 +589,9 @@ export class Context { */ allocateId(): ts.Identifier { return ts.createIdentifier(`_t${this.nextId++}`); } - getPipeByName(name: string): ts.Expression { + getPipeByName(name: string): ts.Expression|null { if (!this.pipes.has(name)) { - throw new Error(`Missing pipe: ${name}`); + return null; } return this.env.pipeInst(this.pipes.get(name) !); } @@ -797,6 +811,7 @@ class Scope { for (const child of node.children) { this.appendNode(child); } + this.checkReferencesOfNode(node); } else if (node instanceof TmplAstTemplate) { // Template children are rendered in a child scope. this.appendDirectivesAndInputsOfNode(node); @@ -806,11 +821,20 @@ class Scope { this.templateCtxOpMap.set(node, ctxIndex); this.opQueue.push(new TcbTemplateBodyOp(this.tcb, this, node)); } + this.checkReferencesOfNode(node); } else if (node instanceof TmplAstBoundText) { this.opQueue.push(new TcbTextInterpolationOp(this.tcb, this, node)); } } + private checkReferencesOfNode(node: TmplAstElement|TmplAstTemplate): void { + for (const ref of node.references) { + if (this.tcb.boundTarget.getReferenceTarget(ref) === null) { + this.tcb.oobRecorder.missingReferenceTarget(this.tcb.id, ref); + } + } + } + private appendDirectivesAndInputsOfNode(node: TmplAstElement|TmplAstTemplate): void { // Collect all the inputs on the element. const claimedInputs = new Set(); @@ -964,9 +988,17 @@ class TcbExpressionTranslator { return ts.createIdentifier('ctx'); } else if (ast instanceof BindingPipe) { const expr = this.translate(ast.exp); - let pipe: ts.Expression; + let pipe: ts.Expression|null; if (this.tcb.env.config.checkTypeOfPipes) { pipe = this.tcb.getPipeByName(ast.name); + if (pipe === null) { + // No pipe by that name exists in scope. Record this as an error. + const nameAbsoluteSpan = toAbsoluteSpan(ast.nameSpan, this.sourceSpan); + this.tcb.oobRecorder.missingPipe(this.tcb.id, ast, nameAbsoluteSpan); + + // Return an 'any' value to at least allow the rest of the expression to be checked. + pipe = NULL_AS_ANY; + } } else { pipe = ts.createParen(ts.createAsExpression( ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword))); @@ -1025,7 +1057,10 @@ class TcbExpressionTranslator { } else if (binding instanceof TmplAstReference) { const target = this.tcb.boundTarget.getReferenceTarget(binding); if (target === null) { - throw new Error(`Unbound reference? ${binding.name}`); + // This reference is unbound. Traversal of the `TmplAstReference` itself should have + // recorded the error in the `OutOfBandDiagnosticRecorder`. + // Still check the rest of the expression if possible by using an `any` value. + return NULL_AS_ANY; } // The reference is either to an element, an node, or to a directive on an diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts index 5875911fd23ac..3a08bde37fb99 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts @@ -15,9 +15,11 @@ import {ImportManager} from '../../translator'; import {TypeCheckBlockMetadata, TypeCheckingConfig} from './api'; import {DomSchemaChecker} from './dom'; import {Environment} from './environment'; +import {OutOfBandDiagnosticRecorder} from './oob'; import {generateTypeCheckBlock} from './type_check_block'; + /** * An `Environment` representing the single type-checking file into which most (if not all) Type * Check Blocks (TCBs) will be generated. @@ -38,9 +40,9 @@ export class TypeCheckFile extends Environment { addTypeCheckBlock( ref: Reference>, meta: TypeCheckBlockMetadata, - domSchemaChecker: DomSchemaChecker): void { + domSchemaChecker: DomSchemaChecker, oobRecorder: OutOfBandDiagnosticRecorder): void { const fnId = ts.createIdentifier(`_tcb${this.nextTcbId++}`); - const fn = generateTypeCheckBlock(this, ref, fnId, meta, domSchemaChecker); + const fn = generateTypeCheckBlock(this, ref, fnId, meta, domSchemaChecker, oobRecorder); this.tcbStatements.push(fn); } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index 3945b02d1d6fb..76962ea6a1754 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, Type, parseTemplate} from '@angular/compiler'; +import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, TmplAstReference, Type, parseTemplate} from '@angular/compiler'; import * as ts from 'typescript'; import {AbsoluteFsPath, LogicalFileSystem, absoluteFrom} from '../../file_system'; @@ -19,6 +19,7 @@ import {TemplateSourceMapping, TypeCheckBlockMetadata, TypeCheckableDirectiveMet import {TypeCheckContext} from '../src/context'; import {DomSchemaChecker} from '../src/dom'; import {Environment} from '../src/environment'; +import {OutOfBandDiagnosticRecorder} from '../src/oob'; import {generateTypeCheckBlock} from '../src/type_check_block'; export function typescriptLibDts(): TestFile { @@ -220,7 +221,7 @@ export function tcb( const tcb = generateTypeCheckBlock( FakeEnvironment.newFake(config), new Reference(clazz), ts.createIdentifier('Test_TCB'), meta, - new NoopSchemaChecker()); + new NoopSchemaChecker(), new NoopOobRecorder()); const removeComments = !options.emitSpans; const res = ts.createPrinter({removeComments}).printNode(ts.EmitHint.Unspecified, tcb, sf); @@ -382,3 +383,9 @@ export class NoopSchemaChecker implements DomSchemaChecker { id: string, element: TmplAstElement, name: string, span: ParseSourceSpan, schemas: SchemaMetadata[]): void {} } + +export class NoopOobRecorder implements OutOfBandDiagnosticRecorder { + get diagnostics(): ReadonlyArray { return []; } + missingReferenceTarget(): void {} + missingPipe(): void {} +} diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index df808e4bdbaa8..6d1704c406291 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -3102,6 +3102,24 @@ runInEachFileSystem(os => { }); }); + describe('local refs', () => { + it('should not generate an error when a local ref is unresolved' + + ' (outside of template type-checking)', + () => { + + env.write('test.ts', ` + import {Component} from '@angular/core'; + + @Component({ + template: '
', + }) + export class TestCmp {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); + describe('multiple local refs', () => { const getComponentScript = (template: string): string => ` import {Component, Directive, NgModule} from '@angular/core'; diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 9522cf8a82e2b..a7d4cb52fb1ae 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -714,6 +714,50 @@ export declare class AnimationEvent { env.driveMain(); }); + it('should report an error with an unknown local ref target', () => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp {} + + @NgModule({ + declarations: [TestCmp], + }) + class Module {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe(`No directive found with exportAs 'unknownTarget'.`); + expect(getSourceCodeForDiagnostic(diags[0])).toBe('unknownTarget'); + }); + + it('should report an error with an unknown pipe', () => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'test', + template: '{{expr | unknown}}', + }) + class TestCmp { + expr = 3; + } + + @NgModule({ + declarations: [TestCmp], + }) + class Module {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe(`No pipe found with name 'unknown'.`); + expect(getSourceCodeForDiagnostic(diags[0])).toBe('unknown'); + }); + it('should report an error with pipe bindings', () => { env.write('test.ts', ` import {CommonModule} from '@angular/common'; diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index 5323373a1959b..2b7052e7919ef 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -145,7 +145,7 @@ export class KeyedWrite extends AST { export class BindingPipe extends AST { constructor( span: ParseSpan, sourceSpan: AbsoluteSourceSpan, public exp: AST, public name: string, - public args: any[]) { + public args: any[], public nameSpan: ParseSpan) { super(span, sourceSpan); } visit(visitor: AstVisitor, context: any = null): any { return visitor.visitPipe(this, context); } @@ -484,7 +484,8 @@ export class AstTransformer implements AstVisitor { visitPipe(ast: BindingPipe, context: any): AST { return new BindingPipe( - ast.span, ast.sourceSpan, ast.exp.visit(this), ast.name, this.visitAll(ast.args)); + ast.span, ast.sourceSpan, ast.exp.visit(this), ast.name, this.visitAll(ast.args), + ast.nameSpan); } visitKeyedRead(ast: KeyedRead, context: any): AST { @@ -635,7 +636,7 @@ export class AstMemoryEfficientTransformer implements AstVisitor { const exp = ast.exp.visit(this); const args = this.visitAll(ast.args); if (exp !== ast.exp || args !== ast.args) { - return new BindingPipe(ast.span, ast.sourceSpan, exp, ast.name, args); + return new BindingPipe(ast.span, ast.sourceSpan, exp, ast.name, args, ast.nameSpan); } return ast; } diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 4a6ca50aeed9a..cc78d116032f2 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -348,13 +348,16 @@ export class _ParseAST { } do { + const nameStart = this.inputIndex; const name = this.expectIdentifierOrKeyword(); + const nameSpan = this.span(nameStart); const args: AST[] = []; while (this.optionalCharacter(chars.$COLON)) { args.push(this.parseExpression()); } const {start} = result.span; - result = new BindingPipe(this.span(start), this.sourceSpan(start), result, name, args); + result = + new BindingPipe(this.span(start), this.sourceSpan(start), result, name, args, nameSpan); } while (this.optionalOperator('|')); } diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index a1eb09df4b0cc..e684b29e0a164 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -260,16 +260,16 @@ class DirectiveBinder implements Visitor { // This could be a reference to a component if there is one. dirTarget = directives.find(dir => dir.isComponent) || null; } else { - // This is a reference to a directive exported via exportAs. One should exist. + // This should be a reference to a directive exported via exportAs. dirTarget = directives.find( dir => dir.exportAs !== null && dir.exportAs.some(value => value === ref.value)) || null; - - // Check if a matching directive was found, and error if it wasn't. + // Check if a matching directive was found. if (dirTarget === null) { - // TODO(alxhub): Return an error value here that can be used for template validation. - throw new Error(`Assertion error: failed to find directive with exportAs: ${ref.value}`); + // No matching directive was found - this reference points to an unknown target. Leave it + // unmapped. + return; } }