diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index 79fdc1b32a8..b6b5f38451a 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -24,7 +24,7 @@ import {setUpAttributes} from '../util/attrs_utils'; import {getConstant} from '../util/view_utils'; import {setDirectiveInputsWhichShadowsStyling} from './property'; -import {createDirectivesInstances, executeContentQueries, getOrCreateTNode, matchingSchemas, resolveDirectives, saveResolvedLocalsInData} from './shared'; +import {createDirectivesInstances, executeContentQueries, getOrCreateTNode, isHostComponentStandalone, matchingSchemas, resolveDirectives, saveResolvedLocalsInData} from './shared'; let shouldThrowErrorOnUnknownElement = false; @@ -56,7 +56,10 @@ function elementStartFirstCreatePass( const hasDirectives = resolveDirectives(tView, lView, tNode, getConstant(tViewConsts, localRefsIndex)); - ngDevMode && validateElementIsKnown(native, tNode.value, tView.schemas, hasDirectives); + if (ngDevMode) { + const hostIsStandalone = isHostComponentStandalone(lView); + validateElementIsKnown(native, tNode.value, tView.schemas, hasDirectives, hostIsStandalone); + } if (tNode.attrs !== null) { computeStaticStyling(tNode, tNode.attrs, false); @@ -223,10 +226,11 @@ export function ɵɵelement( * @param tagName Name of the tag to check * @param schemas Array of schemas * @param hasDirectives Boolean indicating that the element matches any directive + * @param hostIsStandalone Boolean indicating whether the host is a standalone component */ function validateElementIsKnown( - element: RElement, tagName: string|null, schemas: SchemaMetadata[]|null, - hasDirectives: boolean): void { + element: RElement, tagName: string|null, schemas: SchemaMetadata[]|null, hasDirectives: boolean, + hostIsStandalone: boolean): void { // If `schemas` is set to `null`, that's an indication that this Component was compiled in AOT // mode where this check happens at compile time. In JIT mode, `schemas` is always present and // defined as an array (as an empty array in case `schemas` field is not defined) and we should @@ -247,15 +251,18 @@ function validateElementIsKnown( !customElements.get(tagName)); if (isUnknown && !matchingSchemas(schemas, tagName)) { + const schemas = `'${hostIsStandalone ? '@Component' : '@NgModule'}.schemas'`; let message = `'${tagName}' is not a known element:\n`; - message += `1. If '${ - tagName}' is an Angular component, then verify that it is part of this module.\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`; if (tagName && tagName.indexOf('-') > -1) { - message += `2. If '${ - tagName}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.`; + message += + `2. If '${tagName}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the ${ + schemas} of this component to suppress this message.`; } else { message += - `2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.`; + `2. To allow any element add 'NO_ERRORS_SCHEMA' to the ${schemas} of this component.`; } if (shouldThrowErrorOnUnknownElement) { throw new RuntimeError(RuntimeErrorCode.UNKNOWN_ELEMENT, message); diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 95109769746..dbbb824e9aa 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -9,16 +9,18 @@ import {Injector} from '../../di'; import {ErrorHandler} from '../../error_handler'; import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../../errors'; import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks'; +import {Type} from '../../interface/type'; import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from '../../metadata/schema'; import {ViewEncapsulation} from '../../metadata/view'; import {validateAgainstEventAttributes, validateAgainstEventProperties} from '../../sanitization/sanitization'; import {Sanitizer} from '../../sanitization/sanitizer'; -import {assertDefined, assertDomNode, assertEqual, assertGreaterThanOrEqual, assertIndexInRange, assertNotEqual, assertNotSame, assertSame, assertString} from '../../util/assert'; +import {assertDefined, assertDomNode, assertEqual, assertGreaterThanOrEqual, assertIndexInRange, assertNotEqual, assertNotSame, assertSame, assertString, throwError} from '../../util/assert'; import {escapeCommentText} from '../../util/dom'; import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../util/ng_reflect'; import {stringify} from '../../util/stringify'; import {assertFirstCreatePass, assertFirstUpdatePass, assertLContainer, assertLView, assertTNodeForLView, assertTNodeForTView} from '../assert'; import {attachPatchData, readPatchedLView} from '../context_discovery'; +import {getComponentDef} from '../definition'; import {getFactoryDef} from '../definition_factory'; import {diPublicInInjector, getNodeInjectable, getOrCreateNodeInjectorForNode} from '../di'; import {throwMultipleComponentError} from '../errors'; @@ -266,6 +268,23 @@ export function createTNodeAtIndex( return tNode; } +/** + * Checks if the current component is declared inside of a standalone component template. + * + * @param lView An `LView` that represents a current component that is being rendered. + */ +export function isHostComponentStandalone(lView: LView): boolean { + !ngDevMode && throwError('Must never be called in production mode'); + + const declarationLView = lView[DECLARATION_COMPONENT_VIEW] as LView>; + const context = declarationLView[CONTEXT]; + + // Unable to obtain a context, fall back to the non-standalone scenario. + if (!context) return false; + + const componentDef = getComponentDef(context.constructor); + return !!(componentDef?.standalone); +} /** * When elements are created dynamically after a view blueprint is created (e.g. through diff --git a/packages/core/test/acceptance/standalone_spec.ts b/packages/core/test/acceptance/standalone_spec.ts index d8d9858f88b..c394c456b86 100644 --- a/packages/core/test/acceptance/standalone_spec.ts +++ b/packages/core/test/acceptance/standalone_spec.ts @@ -630,6 +630,82 @@ describe('standalone components, directives and pipes', () => { }); }); + describe('unknown template elements', () => { + const unknownElErrorRegex = (tag: string) => { + const prefix = `'${tag}' is not a known element:`; + const message1 = `1. If '${ + tag}' is an Angular component, then verify that it is included in the '@Component.imports' of this component.`; + const message2 = `2. If '${ + tag}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.`; + return new RegExp(`${prefix}\s*\n\s*${message1}\s*\n\s*${message2}`); + }; + + it('should warn the user when an unknown element is present', () => { + const spy = spyOn(console, 'error'); + @Component({ + standalone: true, + template: '', + }) + class AppCmp { + } + + TestBed.createComponent(AppCmp); + + const errorRegex = unknownElErrorRegex('unknown-tag'); + expect(spy).toHaveBeenCalledOnceWith(jasmine.stringMatching(errorRegex)); + }); + + it('should warn the user when multiple unknown elements are present', () => { + const spy = spyOn(console, 'error'); + @Component({ + standalone: true, + template: '', + }) + class AppCmp { + } + + TestBed.createComponent(AppCmp); + + const errorRegexA = unknownElErrorRegex('unknown-tag-A'); + const errorRegexB = unknownElErrorRegex('unknown-tag-B'); + + expect(spy).toHaveBeenCalledWith(jasmine.stringMatching(errorRegexA)); + expect(spy).toHaveBeenCalledWith(jasmine.stringMatching(errorRegexB)); + }); + + it('should not warn the user when an unknown element is present inside an ng-template', () => { + const spy = spyOn(console, 'error'); + @Component({ + standalone: true, + template: '', + }) + class AppCmp { + } + + TestBed.createComponent(AppCmp); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should warn the user when an unknown element is present in an instantiated embedded view', + () => { + const spy = spyOn(console, 'error'); + @Component({ + standalone: true, + template: '', + imports: [CommonModule], + }) + class AppCmp { + } + + const fixture = TestBed.createComponent(AppCmp); + fixture.detectChanges(); + + const errorRegex = unknownElErrorRegex('unknown-tag'); + expect(spy).toHaveBeenCalledOnceWith(jasmine.stringMatching(errorRegex)); + }); + }); + /* The following test verify that we don't impose limits when it comes to extending components of various type (standalone vs. non-standalone).