diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index b6b5f38451a..829f00874e2 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -255,7 +255,7 @@ function validateElementIsKnown( let message = `'${tagName}' is not a known element:\n`; message += `1. If '${tagName}' is an Angular component, then verify that it is ${ hostIsStandalone ? 'included in the \'@Component.imports\' of this component' : - 'a part of this module'}.\n`; + 'a part of an @NgModule where this component is declared'}.\n`; if (tagName && tagName.indexOf('-') > -1) { message += `2. If '${tagName}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the ${ diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 8e06356f1e1..7601b4abca1 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -1053,7 +1053,7 @@ export function elementPropertyInternal( validateAgainstEventProperties(propName); if (!validateProperty(element, tNode.value, propName, tView.schemas)) { // Return here since we only log warnings for unknown properties. - handleUnknownPropertyError(propName, tNode); + handleUnknownPropertyError(propName, tNode, isHostComponentStandalone(lView)); return; } ngDevMode.rendererSetProperty++; @@ -1072,7 +1072,7 @@ export function elementPropertyInternal( // If the node is a container and the property didn't // match any of the inputs or schemas we should throw. if (ngDevMode && !matchingSchemas(tView.schemas, tNode.value)) { - handleUnknownPropertyError(propName, tNode); + handleUnknownPropertyError(propName, tNode, isHostComponentStandalone(lView)); } } } @@ -1184,12 +1184,22 @@ export function matchingSchemas(schemas: SchemaMetadata[]|null, tagName: string| return false; } +/** + * The set of known control flow directives. + * We use this set to produce a more precises error message with a note + * that the `CommonModule` should also be included. + */ +export const KNOWN_CONTROL_FLOW_DIRECTIVES = + new Set(['ngIf', 'ngFor', 'ngSwitch', 'ngSwitchCase', 'ngSwitchDefault']); + /** * Logs or throws an error that a property is not supported on an element. * @param propName Name of the invalid property. * @param tagName Name of the node on which we encountered the property. + * @param hostIsStandalone Boolean indicating whether the host is a standalone component. */ -function handleUnknownPropertyError(propName: string, tNode: TNode): void { +function handleUnknownPropertyError( + propName: string, tNode: TNode, hostIsStandalone: boolean): void { let tagName = tNode.value; // Special-case a situation when a structural directive is applied to @@ -1201,7 +1211,34 @@ function handleUnknownPropertyError(propName: string, tNode: TNode): void { if (!tagName && tNode.type === TNodeType.Container) { tagName = 'ng-template'; } - const message = `Can't bind to '${propName}' since it isn't a known property of '${tagName}'.`; + let message = `Can't bind to '${propName}' since it isn't a known property of '${tagName}'.`; + + const schemas = `'${hostIsStandalone ? '@Component' : '@NgModule'}.schemas'`; + const importLocation = hostIsStandalone ? + 'included in the \'@Component.imports\' of this component' : + 'a part of an @NgModule where this component is declared'; + if (KNOWN_CONTROL_FLOW_DIRECTIVES.has(propName)) { + // Most likely this is a control flow directive (such as `*ngIf`) used in + // a template, but the `CommonModule` is not imported. + message += `\nIf the '${propName}' is an Angular control flow directive, ` + + `please make sure that the 'CommonModule' is ${importLocation}.`; + } else { + // May be an Angular component, which is not imported/declared? + message += `\n1. If '${tagName}' is an Angular component and it has the ` + + `'${propName}' input, then verify that it is ${importLocation}.`; + // May be a Web Component? + if (tagName && tagName.indexOf('-') > -1) { + message += `\n2. If '${tagName}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' ` + + `to the ${schemas} of this component to suppress this message.`; + message += `\n3. To allow any property add 'NO_ERRORS_SCHEMA' to ` + + `the ${schemas} of this component.`; + } else { + // If it's expected, the error can be suppressed by the `NO_ERRORS_SCHEMA` schema. + message += `\n2. To allow any property add 'NO_ERRORS_SCHEMA' to ` + + `the ${schemas} of this component.`; + } + } + if (shouldThrowErrorOnUnknownProperty) { throw new RuntimeError(RuntimeErrorCode.UNKNOWN_BINDING, message); } else { diff --git a/packages/core/test/acceptance/ng_module_spec.ts b/packages/core/test/acceptance/ng_module_spec.ts index 24439874aa4..8d191aa3a57 100644 --- a/packages/core/test/acceptance/ng_module_spec.ts +++ b/packages/core/test/acceptance/ng_module_spec.ts @@ -8,6 +8,7 @@ import {CommonModule} from '@angular/common'; import {Component, createNgModuleRef, CUSTOM_ELEMENTS_SCHEMA, destroyPlatform, Directive, Injectable, InjectionToken, NgModule, NgModuleRef, NO_ERRORS_SCHEMA, Pipe, ɵsetClassMetadata as setClassMetadata, ɵɵdefineComponent as defineComponent, ɵɵdefineInjector as defineInjector, ɵɵdefineNgModule as defineNgModule, ɵɵelement as element, ɵɵproperty as property} from '@angular/core'; +import {KNOWN_CONTROL_FLOW_DIRECTIVES} from '@angular/core/src/render3/instructions/shared'; import {TestBed} from '@angular/core/testing'; import {BrowserModule} from '@angular/platform-browser'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; @@ -590,6 +591,106 @@ describe('NgModule', () => { /NG0303: Can't bind to 'unknown-prop' since it isn't a known property of 'ng-container'/g); }); + it('should log an error on unknown props and include a note on Web Components', () => { + @Component({ + selector: 'may-be-web-component', + template: `...`, + }) + class MaybeWebComp { + } + + @Component({ + selector: 'my-comp', + template: ``, + }) + class MyComp { + condition = true; + } + + @NgModule({ + declarations: [MyComp, MaybeWebComp], + }) + class MyModule { + } + + TestBed.configureTestingModule({imports: [MyModule]}); + + const spy = spyOn(console, 'error'); + const fixture = TestBed.createComponent(MyComp); + fixture.detectChanges(); + + const errorMessage = spy.calls.mostRecent().args[0]; + + // Split the error message into chunks, so it's easier to debug if needed. + const lines = [ + `NG0303: Can't bind to 'unknownProp' since it isn't a known property of 'may-be-web-component'.`, + `1. If 'may-be-web-component' is an Angular component and it has the 'unknownProp' input, then verify that it is a part of an @NgModule where this component is declared.`, + `2. If 'may-be-web-component' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.`, + `3. To allow any property add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.` + ]; + lines.forEach(line => expect(errorMessage).toMatch(line)); + }); + + KNOWN_CONTROL_FLOW_DIRECTIVES.forEach(directive => { + it(`should produce a warning when the '${directive}' directive ` + + `is used in a template, but not imported in corresponding NgModule`, + () => { + @Component({ + template: `
`, + }) + class App { + expr = true; + } + + @NgModule({ + declarations: [App], + }) + class Module { + } + + TestBed.configureTestingModule({imports: [Module]}); + const spy = spyOn(console, 'error'); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const errorMessage = spy.calls.mostRecent()?.args[0]; + + // Split the error message into chunks, so it's easier to debug if needed. + const lines = [ + `NG0303: Can't bind to '${directive}' since it isn't a known property of 'div'.`, + `If the '${directive}' is an Angular control flow directive, please make sure ` + + `that the 'CommonModule' is a part of an @NgModule where this component is declared.` + ]; + lines.forEach(line => expect(errorMessage).toMatch(line)); + }); + + it(`should produce a warning when the '${directive}' directive ` + + `is used in a template, but not imported in a standalone component`, + () => { + @Component({ + standalone: true, + template: `
`, + }) + class App { + expr = true; + } + + const spy = spyOn(console, 'error'); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const errorMessage = spy.calls.mostRecent()?.args[0]; + + // Split the error message into chunks, so it's easier to debug if needed. + const lines = [ + `NG0303: Can't bind to '${directive}' since it isn't a known property of 'div'.`, + `If the '${directive}' is an Angular control flow directive, please make sure ` + + `that the 'CommonModule' is included in the '@Component.imports' of this component.` + ]; + lines.forEach(line => expect(errorMessage).toMatch(line)); + }); + }); + describe('AOT-compiled components', () => { function createComponent( template: (rf: any) => void, vars: number, consts?: (number|string)[][]) {