Skip to content

Commit

Permalink
feat(compiler): allow self-closing tags on custom elements (#48535)
Browse files Browse the repository at this point in the history
Allows for self-closing tags to be used for non-native tag names, e.g. `<foo [input]="bar"></foo>` can now be written as `<foo [input]="bar"/>`. Native tag names still have to have closing tags.

Fixes #39525.

PR Close #48535
  • Loading branch information
crisbeto authored and alxhub committed Jan 4, 2023
1 parent b3fca32 commit a532d71
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -921,3 +921,107 @@ export declare class MyModule {
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

/****************************************************************************************************
* PARTIAL FILE: self_closing_tags.js
****************************************************************************************************/
import { Component, NgModule } from '@angular/core';
import * as i0 from "@angular/core";
export class MyComp {
}
MyComp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComp, selector: "my-comp", ngImport: i0, template: 'hello', isInline: true });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, decorators: [{
type: Component,
args: [{ selector: 'my-comp', template: 'hello' }]
}] });
export class App {
}
App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component });
App.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: App, selector: "ng-component", ngImport: i0, template: `<my-comp/>`, isInline: true, dependencies: [{ kind: "component", type: MyComp, selector: "my-comp" }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{
type: Component,
args: [{ template: `<my-comp/>` }]
}] });
export class MyModule {
}
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [App, MyComp] });
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
type: NgModule,
args: [{ declarations: [App, MyComp] }]
}] });

/****************************************************************************************************
* PARTIAL FILE: self_closing_tags.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class MyComp {
static ɵfac: i0.ɵɵFactoryDeclaration<MyComp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyComp, "my-comp", never, {}, {}, never, never, false, never>;
}
export declare class App {
static ɵfac: i0.ɵɵFactoryDeclaration<App, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<App, "ng-component", never, {}, {}, never, never, false, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof App, typeof MyComp], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

/****************************************************************************************************
* PARTIAL FILE: self_closing_tags_nested.js
****************************************************************************************************/
import { Component, NgModule } from '@angular/core';
import * as i0 from "@angular/core";
export class MyComp {
}
MyComp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComp, selector: "my-comp", ngImport: i0, template: 'hello', isInline: true });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, decorators: [{
type: Component,
args: [{ selector: 'my-comp', template: 'hello' }]
}] });
export class App {
}
App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component });
App.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: App, selector: "ng-component", ngImport: i0, template: `
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
`, isInline: true, dependencies: [{ kind: "component", type: MyComp, selector: "my-comp" }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{
type: Component,
args: [{
template: `
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
`
}]
}] });
export class MyModule {
}
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [App, MyComp] });
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
type: NgModule,
args: [{ declarations: [App, MyComp] }]
}] });

/****************************************************************************************************
* PARTIAL FILE: self_closing_tags_nested.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class MyComp {
static ɵfac: i0.ɵɵFactoryDeclaration<MyComp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyComp, "my-comp", never, {}, {}, never, never, false, never>;
}
export declare class App {
static ɵfac: i0.ɵɵFactoryDeclaration<App, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<App, "ng-component", never, {}, {}, never, never, false, never>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof App, typeof MyComp], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,40 @@
"failureMessage": "Incorrect template"
}
]
},
{
"description": "should allow self-closing custom elements in templates",
"inputFiles": [
"self_closing_tags.ts"
],
"expectations": [
{
"files": [
{
"expected": "self_closing_tags_template.js",
"generated": "self_closing_tags.js"
}
],
"failureMessage": "Incorrect template"
}
]
},
{
"description": "should not confuse self-closing tag for an end tag",
"inputFiles": [
"self_closing_tags_nested.ts"
],
"expectations": [
{
"files": [
{
"expected": "self_closing_tags_nested_template.js",
"generated": "self_closing_tags_nested.js"
}
],
"failureMessage": "Incorrect template"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Component, NgModule} from '@angular/core';

@Component({selector: 'my-comp', template: 'hello'})
export class MyComp {
}

@Component({template: `<my-comp/>`})
export class App {
}

@NgModule({declarations: [App, MyComp]})
export class MyModule {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Component, NgModule} from '@angular/core';

@Component({selector: 'my-comp', template: 'hello'})
export class MyComp {
}

@Component({
template: `
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
`
})
export class App {
}

@NgModule({declarations: [App, MyComp]})
export class MyModule {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
template: function App_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelementStart(0, "my-comp", 0);
i0.ɵɵtext(1, "Before");
i0.ɵɵelement(2, "my-comp", 1);
i0.ɵɵtext(3, "After");
i0.ɵɵelementEnd();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
template: function App_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelement(0, "my-comp");
}
}
27 changes: 19 additions & 8 deletions packages/compiler/src/ml_parser/html_tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@
* found in the LICENSE file at https://angular.io/license
*/

import {TagContentType, TagDefinition} from './tags';
import {DomElementSchemaRegistry} from '../schema/dom_element_schema_registry';

import {getNsPrefix, TagContentType, TagDefinition} from './tags';

export class HtmlTagDefinition implements TagDefinition {
private closedByChildren: {[key: string]: boolean} = {};
private contentType: TagContentType|
{default: TagContentType, [namespace: string]: TagContentType};

closedByParent: boolean = false;
closedByParent = false;
implicitNamespacePrefix: string|null;
isVoid: boolean;
ignoreFirstLf: boolean;
canSelfClose: boolean = false;
canSelfClose: boolean;
preventNamespaceInheritance: boolean;

constructor({
Expand All @@ -27,15 +29,17 @@ export class HtmlTagDefinition implements TagDefinition {
closedByParent = false,
isVoid = false,
ignoreFirstLf = false,
preventNamespaceInheritance = false
preventNamespaceInheritance = false,
canSelfClose = false,
}: {
closedByChildren?: string[],
closedByParent?: boolean,
implicitNamespacePrefix?: string,
contentType?: TagContentType|{default: TagContentType, [namespace: string]: TagContentType},
isVoid?: boolean,
ignoreFirstLf?: boolean,
preventNamespaceInheritance?: boolean
preventNamespaceInheritance?: boolean,
canSelfClose?: boolean
} = {}) {
if (closedByChildren && closedByChildren.length > 0) {
closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true);
Expand All @@ -46,6 +50,7 @@ export class HtmlTagDefinition implements TagDefinition {
this.contentType = contentType;
this.ignoreFirstLf = ignoreFirstLf;
this.preventNamespaceInheritance = preventNamespaceInheritance;
this.canSelfClose = canSelfClose ?? isVoid;
}

isClosedByChild(name: string): boolean {
Expand All @@ -61,15 +66,15 @@ export class HtmlTagDefinition implements TagDefinition {
}
}

let _DEFAULT_TAG_DEFINITION!: HtmlTagDefinition;
let DEFAULT_TAG_DEFINITION!: HtmlTagDefinition;

// see https://www.w3.org/TR/html51/syntax.html#optional-tags
// This implementation does not fully conform to the HTML5 spec.
let TAG_DEFINITIONS!: {[key: string]: HtmlTagDefinition};

export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition {
if (!TAG_DEFINITIONS) {
_DEFAULT_TAG_DEFINITION = new HtmlTagDefinition();
DEFAULT_TAG_DEFINITION = new HtmlTagDefinition({canSelfClose: true});
TAG_DEFINITIONS = {
'base': new HtmlTagDefinition({isVoid: true}),
'meta': new HtmlTagDefinition({isVoid: true}),
Expand Down Expand Up @@ -138,9 +143,15 @@ export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition {
'textarea': new HtmlTagDefinition(
{contentType: TagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}),
};

new DomElementSchemaRegistry().allKnownElementNames().forEach(knownTagName => {
if (!TAG_DEFINITIONS.hasOwnProperty(knownTagName) && getNsPrefix(knownTagName) === null) {
TAG_DEFINITIONS[knownTagName] = new HtmlTagDefinition({canSelfClose: false});
}
});
}
// We have to make both a case-sensitive and a case-insensitive lookup, because
// HTML tag names are case insensitive, whereas some SVG tags are case sensitive.
return TAG_DEFINITIONS[tagName] ?? TAG_DEFINITIONS[tagName.toLowerCase()] ??
_DEFAULT_TAG_DEFINITION;
DEFAULT_TAG_DEFINITION;
}
3 changes: 2 additions & 1 deletion packages/compiler/src/ml_parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ class _TreeBuilder {
if (!(tagDef.canSelfClose || getNsPrefix(fullName) !== null || tagDef.isVoid)) {
this.errors.push(TreeError.create(
fullName, startTagToken.sourceSpan,
`Only void and foreign elements can be self closed "${startTagToken.parts[1]}"`));
`Only void, custom and foreign elements can be self closed "${
startTagToken.parts[1]}"`));
}
} else if (this._peek.type === TokenType.TAG_OPEN_END) {
this._advance();
Expand Down
18 changes: 0 additions & 18 deletions packages/compiler/src/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

import {getHtmlTagDefinition} from './ml_parser/html_tags';

const _SELECTOR_REGEXP = new RegExp(
'(\\:not\\()|' + // 1: ":not("
'(([\\.\\#]?)[-\\w]+)|' + // 2: "tag"; 3: "."/"#";
Expand Down Expand Up @@ -172,22 +170,6 @@ export class CssSelector {
this.element = element;
}

/** Gets a template string for an element that matches the selector. */
getMatchingElementTemplate(): string {
const tagName = this.element || 'div';
const classAttr = this.classNames.length > 0 ? ` class="${this.classNames.join(' ')}"` : '';

let attrs = '';
for (let i = 0; i < this.attrs.length; i += 2) {
const attrName = this.attrs[i];
const attrValue = this.attrs[i + 1] !== '' ? `="${this.attrs[i + 1]}"` : '';
attrs += ` ${attrName}${attrValue}`;
}

return getHtmlTagDefinition(tagName).isVoid ? `<${tagName}${classAttr}${attrs}/>` :
`<${tagName}${classAttr}${attrs}></${tagName}>`;
}

getAttrs(): string[] {
const result: string[] = [];
if (this.classNames.length > 0) {
Expand Down
12 changes: 4 additions & 8 deletions packages/compiler/test/ml_parser/html_parser_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn, humanizeNodes}
const p = parser.parse(
`{messages.length, plural, =0 {<b/>}`, 'TestComp', {tokenizeExpansionForms: true});
expect(humanizeErrors(p.errors)).toEqual([
['b', 'Only void and foreign elements can be self closed "b"', '0:30']
['b', 'Only void, custom and foreign elements can be self closed "b"', '0:30']
]);
});
});
Expand Down Expand Up @@ -1117,16 +1117,12 @@ import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn, humanizeNodes}
const errors = parser.parse('<p />', 'TestComp').errors;
expect(errors.length).toEqual(1);
expect(humanizeErrors(errors)).toEqual([
['p', 'Only void and foreign elements can be self closed "p"', '0:0']
['p', 'Only void, custom and foreign elements can be self closed "p"', '0:0']
]);
});

it('should report self closing custom element', () => {
const errors = parser.parse('<my-cmp />', 'TestComp').errors;
expect(errors.length).toEqual(1);
expect(humanizeErrors(errors)).toEqual([
['my-cmp', 'Only void and foreign elements can be self closed "my-cmp"', '0:0']
]);
it('should not report self closing custom element', () => {
expect(parser.parse('<my-cmp />', 'TestComp').errors).toEqual([]);
});

it('should also report lexer errors', () => {
Expand Down
30 changes: 0 additions & 30 deletions packages/compiler/test/selector/selector_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,36 +512,6 @@ import {el} from '@angular/platform-browser/testing/src/browser_util';
expect(cssSelectors[2].notSelectors[0].classNames).toEqual(['special']);
});
});

describe('CssSelector.getMatchingElementTemplate', () => {
it('should create an element with a tagName, classes, and attributes with the correct casing',
() => {
const selector = CssSelector.parse('Blink.neon.hotpink[Sweet][Dismissable=false]')[0];
const template = selector.getMatchingElementTemplate();

expect(template).toEqual('<Blink class="neon hotpink" Sweet Dismissable="false"></Blink>');
});

it('should create an element without a tag name', () => {
const selector = CssSelector.parse('[fancy]')[0];
const template = selector.getMatchingElementTemplate();

expect(template).toEqual('<div fancy></div>');
});

it('should ignore :not selectors', () => {
const selector = CssSelector.parse('grape:not(.red)')[0];
const template = selector.getMatchingElementTemplate();

expect(template).toEqual('<grape></grape>');
});

it('should support void tags', () => {
const selector = CssSelector.parse('input[fancy]')[0];
const template = selector.getMatchingElementTemplate();
expect(template).toEqual('<input fancy/>');
});
});
}

function getSelectorFor(
Expand Down
Loading

0 comments on commit a532d71

Please sign in to comment.