Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(compiler): allow self-closing tags on custom elements
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.
  • Loading branch information
crisbeto committed Dec 20, 2022
commit 6cd07d77e79b0f61ce309b35200764fcc761cabd
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 => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change here and to the DEFAULT_TAG_DEFINITION are the key ones. Now we mark any element from the DOM schema, that hasn't been added to the tag definition already, as not being able to self-close, whereas all unknown elements are allowed to self-close.

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. */

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context here is that this would've caused a circular import with my latest changes. I removed the code, because it wasn't being referenced anywhere except the unit tests for the method.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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