feat(compiler): emit diagnostic for shadow dom components with an invalid selector (#42245)

This is based on a discussion we had a few weeks ago. Currently if a component uses `ViewEncapsulation.ShadowDom` and its selector doesn't meet the requirements for a custom element tag name, a vague error will be thrown at runtime saying something like "Element does not support attachShadowRoot".

These changes add a new diagnostic to the compiler that validates the component selector and gives a better error message during compilation.

PR Close #42245
This commit is contained in:
Kristiyan Kostadinov 2021-05-23 12:14:02 +02:00 committed by Jessica Janiuk
parent cc904b5226
commit afd68e5674
5 changed files with 197 additions and 12 deletions

View File

@ -0,0 +1,32 @@
@name Invalid Shadow DOM selector
@category compiler
@shortDescription Component selector does not match shadow DOM requirements
@description
The selector of a component using `ViewEncapsulation.ShadowDom` doesn't match the custom element tag name requirements.
In order for a tag name to be considered a valid custom element name, it has to:
* Be in lower case.
* Contain a hyphen.
* Start with a letter (a-z).
@debugging
Rename your component's selector so that it matches the requirements.
**Before:**
```typescript
@Component({
selector: 'comp',
encapsulation: ViewEncapsulation.ShadowDom
...
})
```
**After:**
```typescript
@Component({
selector: 'app-comp',
encapsulation: ViewEncapsulation.ShadowDom
...
})
```

View File

@ -15,6 +15,7 @@ export declare enum ErrorCode {
DIRECTIVE_INHERITS_UNDECORATED_CTOR = 2006,
UNDECORATED_CLASS_USING_ANGULAR_FEATURES = 2007,
COMPONENT_RESOURCE_NOT_FOUND = 2008,
COMPONENT_INVALID_SHADOW_DOM_SELECTOR = 2009,
SYMBOL_NOT_EXPORTED = 3001,
SYMBOL_EXPORTED_UNDER_DIFFERENT_NAME = 3002,
IMPORT_CYCLE_DETECTED = 3003,

View File

@ -7,10 +7,11 @@
*/
import {compileClassMetadata, compileComponentFromMetadata, compileDeclareClassMetadata, compileDeclareComponentFromMetadata, ConstantPool, CssSelector, DeclarationListEmitMode, DeclareComponentTemplateInfo, DEFAULT_INTERPOLATION_CONFIG, DomElementSchemaRegistry, Expression, ExternalExpr, FactoryTarget, InterpolationConfig, LexerRange, makeBindingParser, ParsedTemplate, ParseSourceFile, parseTemplate, R3ClassMetadata, R3ComponentMetadata, R3TargetBinder, R3UsedDirectiveMetadata, SelectorMatcher, Statement, TmplAstNode, WrappedNodeExpr} from '@angular/compiler';
import {ViewEncapsulation} from '@angular/compiler/src/core';
import * as ts from 'typescript';
import {Cycle, CycleAnalyzer, CycleHandlingStrategy} from '../../cycles';
import {ErrorCode, FatalDiagnosticError, makeRelatedInformation} from '../../diagnostics';
import {ErrorCode, FatalDiagnosticError, makeDiagnostic, makeRelatedInformation} from '../../diagnostics';
import {absoluteFrom, relative} from '../../file_system';
import {ImportedFile, ModuleResolver, Reference, ReferenceEmitter} from '../../imports';
import {DependencyTracker} from '../../incremental/api';
@ -338,6 +339,16 @@ export class ComponentDecoratorHandler implements
// Next, read the `@Component`-specific fields.
const {decorator: component, metadata, inputs, outputs} = directiveResult;
const encapsulation: number =
this._resolveEnumValue(component, 'encapsulation', 'ViewEncapsulation') ??
ViewEncapsulation.Emulated;
const changeDetection: number|null =
this._resolveEnumValue(component, 'changeDetection', 'ChangeDetectionStrategy');
let animations: Expression|null = null;
if (component.has('animations')) {
animations = new WrappedNodeExpr(component.get('animations')!);
}
// Go through the root directories for this project, and select the one with the smallest
// relative path representation.
@ -426,6 +437,18 @@ export class ComponentDecoratorHandler implements
}
}
if (encapsulation === ViewEncapsulation.ShadowDom && metadata.selector !== null) {
const selectorError = checkCustomElementSelectorForErrors(metadata.selector);
if (selectorError !== null) {
if (diagnostics === undefined) {
diagnostics = [];
}
diagnostics.push(makeDiagnostic(
ErrorCode.COMPONENT_INVALID_SHADOW_DOM_SELECTOR, component.get('selector')!,
selectorError));
}
}
// If inline styles were preprocessed use those
let inlineStyles: string[]|null = null;
if (this.preanalyzeStylesCache.has(node)) {
@ -455,17 +478,6 @@ export class ComponentDecoratorHandler implements
styles.push(...template.styles);
}
const encapsulation: number =
this._resolveEnumValue(component, 'encapsulation', 'ViewEncapsulation') || 0;
const changeDetection: number|null =
this._resolveEnumValue(component, 'changeDetection', 'ChangeDetectionStrategy');
let animations: Expression|null = null;
if (component.has('animations')) {
animations = new WrappedNodeExpr(component.get('animations')!);
}
const output: AnalysisOutput<ComponentAnalysisData> = {
analysis: {
baseClass: readBaseClass(node, this.reflector, this.evaluator),
@ -1431,3 +1443,31 @@ function makeCyclicImportInfo(
`The ${type} '${name}' is used in the template but importing it would create a cycle: `;
return makeRelatedInformation(ref.node, message + path);
}
/**
* Checks whether a selector is a valid custom element tag name.
* Based loosely on https://github.com/sindresorhus/validate-element-name.
*/
function checkCustomElementSelectorForErrors(selector: string): string|null {
// Avoid flagging components with an attribute or class selector. This isn't bulletproof since it
// won't catch cases like `foo[]bar`, but we don't need it to be. This is mainly to avoid flagging
// something like `foo-bar[baz]` incorrectly.
if (selector.includes('.') || (selector.includes('[') && selector.includes(']'))) {
return null;
}
if (!(/^[a-z]/.test(selector))) {
return 'Selector of a ShadowDom-encapsulated component must start with a lower case letter.';
}
if (/[A-Z]/.test(selector)) {
return 'Selector of a ShadowDom-encapsulated component must all be in lower case.';
}
if (!selector.includes('-')) {
return 'Selector of a component that uses ViewEncapsulation.ShadowDom must contain a hyphen.';
}
return null;
}

View File

@ -50,6 +50,12 @@ export enum ErrorCode {
*/
COMPONENT_RESOURCE_NOT_FOUND = 2008,
/**
* Raised when a component uses `ShadowDom` view encapsulation, but its selector
* does not match the shadow DOM tag name requirements.
*/
COMPONENT_INVALID_SHADOW_DOM_SELECTOR = 2009,
SYMBOL_NOT_EXPORTED = 3001,
SYMBOL_EXPORTED_UNDER_DIFFERENT_NAME = 3002,
/**
@ -215,6 +221,7 @@ export const COMPILER_ERRORS_WITH_GUIDES = new Set([
ErrorCode.SCHEMA_INVALID_ELEMENT,
ErrorCode.SCHEMA_INVALID_ATTRIBUTE,
ErrorCode.MISSING_REFERENCE_TARGET,
ErrorCode.COMPONENT_INVALID_SHADOW_DOM_SELECTOR,
]);
/**

View File

@ -7864,6 +7864,111 @@ export const Foo = Foo__PRE_R3__;
});
});
describe('shadow DOM selector diagnostics', () => {
it('should emit a diagnostic when a selector does not include a hyphen', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: 'cmp',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(
'Selector of a component that uses ViewEncapsulation.ShadowDom must contain a hyphen.');
expect(getDiagnosticSourceCode(diags[0])).toBe(`'cmp'`);
});
it('should emit a diagnostic when a selector includes uppercase letters', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: 'my-Comp',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe('Selector of a ShadowDom-encapsulated component must all be in lower case.');
expect(getDiagnosticSourceCode(diags[0])).toBe(`'my-Comp'`);
});
it('should emit a diagnostic when a selector starts with a digit', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: '123-comp',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(
'Selector of a ShadowDom-encapsulated component must start with a lower case letter.');
expect(getDiagnosticSourceCode(diags[0])).toBe(`'123-comp'`);
});
it('should emit a diagnostic when a selector starts with a hyphen', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: '-comp',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(
'Selector of a ShadowDom-encapsulated component must start with a lower case letter.');
expect(getDiagnosticSourceCode(diags[0])).toBe(`'-comp'`);
});
it('should not emit a diagnostic for a component using an attribute selector', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: '[button]',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should not emit a diagnostic for a component using a class selector', () => {
env.write('test.ts', `
import {Component, ViewEncapsulation} from '@angular/core';
@Component({
template: '',
selector: '.button',
encapsulation: ViewEncapsulation.ShadowDom
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});
describe('i18n errors', () => {
it('reports a diagnostics on nested i18n sections', () => {
env.write('test.ts', `