Skip to content

Commit a532d71

Browse files
crisbetoalxhub
authored andcommitted
feat(compiler): allow self-closing tags on custom elements (#48535)
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
1 parent b3fca32 commit a532d71

File tree

12 files changed

+244
-65
lines changed

12 files changed

+244
-65
lines changed

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_template/GOLDEN_PARTIAL.js

+104
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,107 @@ export declare class MyModule {
921921
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
922922
}
923923

924+
/****************************************************************************************************
925+
* PARTIAL FILE: self_closing_tags.js
926+
****************************************************************************************************/
927+
import { Component, NgModule } from '@angular/core';
928+
import * as i0 from "@angular/core";
929+
export class MyComp {
930+
}
931+
MyComp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, deps: [], target: i0.ɵɵFactoryTarget.Component });
932+
MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComp, selector: "my-comp", ngImport: i0, template: 'hello', isInline: true });
933+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, decorators: [{
934+
type: Component,
935+
args: [{ selector: 'my-comp', template: 'hello' }]
936+
}] });
937+
export class App {
938+
}
939+
App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component });
940+
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" }] });
941+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{
942+
type: Component,
943+
args: [{ template: `<my-comp/>` }]
944+
}] });
945+
export class MyModule {
946+
}
947+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
948+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [App, MyComp] });
949+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
950+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
951+
type: NgModule,
952+
args: [{ declarations: [App, MyComp] }]
953+
}] });
954+
955+
/****************************************************************************************************
956+
* PARTIAL FILE: self_closing_tags.d.ts
957+
****************************************************************************************************/
958+
import * as i0 from "@angular/core";
959+
export declare class MyComp {
960+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComp, never>;
961+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComp, "my-comp", never, {}, {}, never, never, false, never>;
962+
}
963+
export declare class App {
964+
static ɵfac: i0.ɵɵFactoryDeclaration<App, never>;
965+
static ɵcmp: i0.ɵɵComponentDeclaration<App, "ng-component", never, {}, {}, never, never, false, never>;
966+
}
967+
export declare class MyModule {
968+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
969+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof App, typeof MyComp], never, never>;
970+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
971+
}
972+
973+
/****************************************************************************************************
974+
* PARTIAL FILE: self_closing_tags_nested.js
975+
****************************************************************************************************/
976+
import { Component, NgModule } from '@angular/core';
977+
import * as i0 from "@angular/core";
978+
export class MyComp {
979+
}
980+
MyComp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, deps: [], target: i0.ɵɵFactoryTarget.Component });
981+
MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComp, selector: "my-comp", ngImport: i0, template: 'hello', isInline: true });
982+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComp, decorators: [{
983+
type: Component,
984+
args: [{ selector: 'my-comp', template: 'hello' }]
985+
}] });
986+
export class App {
987+
}
988+
App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component });
989+
App.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: App, selector: "ng-component", ngImport: i0, template: `
990+
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
991+
`, isInline: true, dependencies: [{ kind: "component", type: MyComp, selector: "my-comp" }] });
992+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{
993+
type: Component,
994+
args: [{
995+
template: `
996+
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
997+
`
998+
}]
999+
}] });
1000+
export class MyModule {
1001+
}
1002+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1003+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [App, MyComp] });
1004+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
1005+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
1006+
type: NgModule,
1007+
args: [{ declarations: [App, MyComp] }]
1008+
}] });
1009+
1010+
/****************************************************************************************************
1011+
* PARTIAL FILE: self_closing_tags_nested.d.ts
1012+
****************************************************************************************************/
1013+
import * as i0 from "@angular/core";
1014+
export declare class MyComp {
1015+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComp, never>;
1016+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComp, "my-comp", never, {}, {}, never, never, false, never>;
1017+
}
1018+
export declare class App {
1019+
static ɵfac: i0.ɵɵFactoryDeclaration<App, never>;
1020+
static ɵcmp: i0.ɵɵComponentDeclaration<App, "ng-component", never, {}, {}, never, never, false, never>;
1021+
}
1022+
export declare class MyModule {
1023+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
1024+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof App, typeof MyComp], never, never>;
1025+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
1026+
}
1027+

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_template/TEST_CASES.json

+34
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@
286286
"failureMessage": "Incorrect template"
287287
}
288288
]
289+
},
290+
{
291+
"description": "should allow self-closing custom elements in templates",
292+
"inputFiles": [
293+
"self_closing_tags.ts"
294+
],
295+
"expectations": [
296+
{
297+
"files": [
298+
{
299+
"expected": "self_closing_tags_template.js",
300+
"generated": "self_closing_tags.js"
301+
}
302+
],
303+
"failureMessage": "Incorrect template"
304+
}
305+
]
306+
},
307+
{
308+
"description": "should not confuse self-closing tag for an end tag",
309+
"inputFiles": [
310+
"self_closing_tags_nested.ts"
311+
],
312+
"expectations": [
313+
{
314+
"files": [
315+
{
316+
"expected": "self_closing_tags_nested_template.js",
317+
"generated": "self_closing_tags_nested.js"
318+
}
319+
],
320+
"failureMessage": "Incorrect template"
321+
}
322+
]
289323
}
290324
]
291325
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Component, NgModule} from '@angular/core';
2+
3+
@Component({selector: 'my-comp', template: 'hello'})
4+
export class MyComp {
5+
}
6+
7+
@Component({template: `<my-comp/>`})
8+
export class App {
9+
}
10+
11+
@NgModule({declarations: [App, MyComp]})
12+
export class MyModule {
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {Component, NgModule} from '@angular/core';
2+
3+
@Component({selector: 'my-comp', template: 'hello'})
4+
export class MyComp {
5+
}
6+
7+
@Component({
8+
template: `
9+
<my-comp title="a">Before<my-comp title="b"></my-comp>After</my-comp>
10+
`
11+
})
12+
export class App {
13+
}
14+
15+
@NgModule({declarations: [App, MyComp]})
16+
export class MyModule {
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
template: function App_Template(rf, ctx) {
2+
if (rf & 1) {
3+
4+
i0.ɵɵelementStart(0, "my-comp", 0);
5+
i0.ɵɵtext(1, "Before");
6+
i0.ɵɵelement(2, "my-comp", 1);
7+
i0.ɵɵtext(3, "After");
8+
i0.ɵɵelementEnd();
9+
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
template: function App_Template(rf, ctx) {
2+
if (rf & 1) {
3+
4+
i0.ɵɵelement(0, "my-comp");
5+
6+
}
7+
}

packages/compiler/src/ml_parser/html_tags.ts

+19-8
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {TagContentType, TagDefinition} from './tags';
9+
import {DomElementSchemaRegistry} from '../schema/dom_element_schema_registry';
10+
11+
import {getNsPrefix, TagContentType, TagDefinition} from './tags';
1012

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

16-
closedByParent: boolean = false;
18+
closedByParent = false;
1719
implicitNamespacePrefix: string|null;
1820
isVoid: boolean;
1921
ignoreFirstLf: boolean;
20-
canSelfClose: boolean = false;
22+
canSelfClose: boolean;
2123
preventNamespaceInheritance: boolean;
2224

2325
constructor({
@@ -27,15 +29,17 @@ export class HtmlTagDefinition implements TagDefinition {
2729
closedByParent = false,
2830
isVoid = false,
2931
ignoreFirstLf = false,
30-
preventNamespaceInheritance = false
32+
preventNamespaceInheritance = false,
33+
canSelfClose = false,
3134
}: {
3235
closedByChildren?: string[],
3336
closedByParent?: boolean,
3437
implicitNamespacePrefix?: string,
3538
contentType?: TagContentType|{default: TagContentType, [namespace: string]: TagContentType},
3639
isVoid?: boolean,
3740
ignoreFirstLf?: boolean,
38-
preventNamespaceInheritance?: boolean
41+
preventNamespaceInheritance?: boolean,
42+
canSelfClose?: boolean
3943
} = {}) {
4044
if (closedByChildren && closedByChildren.length > 0) {
4145
closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true);
@@ -46,6 +50,7 @@ export class HtmlTagDefinition implements TagDefinition {
4650
this.contentType = contentType;
4751
this.ignoreFirstLf = ignoreFirstLf;
4852
this.preventNamespaceInheritance = preventNamespaceInheritance;
53+
this.canSelfClose = canSelfClose ?? isVoid;
4954
}
5055

5156
isClosedByChild(name: string): boolean {
@@ -61,15 +66,15 @@ export class HtmlTagDefinition implements TagDefinition {
6166
}
6267
}
6368

64-
let _DEFAULT_TAG_DEFINITION!: HtmlTagDefinition;
69+
let DEFAULT_TAG_DEFINITION!: HtmlTagDefinition;
6570

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

7075
export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition {
7176
if (!TAG_DEFINITIONS) {
72-
_DEFAULT_TAG_DEFINITION = new HtmlTagDefinition();
77+
DEFAULT_TAG_DEFINITION = new HtmlTagDefinition({canSelfClose: true});
7378
TAG_DEFINITIONS = {
7479
'base': new HtmlTagDefinition({isVoid: true}),
7580
'meta': new HtmlTagDefinition({isVoid: true}),
@@ -138,9 +143,15 @@ export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition {
138143
'textarea': new HtmlTagDefinition(
139144
{contentType: TagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}),
140145
};
146+
147+
new DomElementSchemaRegistry().allKnownElementNames().forEach(knownTagName => {
148+
if (!TAG_DEFINITIONS.hasOwnProperty(knownTagName) && getNsPrefix(knownTagName) === null) {
149+
TAG_DEFINITIONS[knownTagName] = new HtmlTagDefinition({canSelfClose: false});
150+
}
151+
});
141152
}
142153
// We have to make both a case-sensitive and a case-insensitive lookup, because
143154
// HTML tag names are case insensitive, whereas some SVG tags are case sensitive.
144155
return TAG_DEFINITIONS[tagName] ?? TAG_DEFINITIONS[tagName.toLowerCase()] ??
145-
_DEFAULT_TAG_DEFINITION;
156+
DEFAULT_TAG_DEFINITION;
146157
}

packages/compiler/src/ml_parser/parser.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ class _TreeBuilder {
279279
if (!(tagDef.canSelfClose || getNsPrefix(fullName) !== null || tagDef.isVoid)) {
280280
this.errors.push(TreeError.create(
281281
fullName, startTagToken.sourceSpan,
282-
`Only void and foreign elements can be self closed "${startTagToken.parts[1]}"`));
282+
`Only void, custom and foreign elements can be self closed "${
283+
startTagToken.parts[1]}"`));
283284
}
284285
} else if (this._peek.type === TokenType.TAG_OPEN_END) {
285286
this._advance();

packages/compiler/src/selector.ts

-18
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {getHtmlTagDefinition} from './ml_parser/html_tags';
10-
119
const _SELECTOR_REGEXP = new RegExp(
1210
'(\\:not\\()|' + // 1: ":not("
1311
'(([\\.\\#]?)[-\\w]+)|' + // 2: "tag"; 3: "."/"#";
@@ -172,22 +170,6 @@ export class CssSelector {
172170
this.element = element;
173171
}
174172

175-
/** Gets a template string for an element that matches the selector. */
176-
getMatchingElementTemplate(): string {
177-
const tagName = this.element || 'div';
178-
const classAttr = this.classNames.length > 0 ? ` class="${this.classNames.join(' ')}"` : '';
179-
180-
let attrs = '';
181-
for (let i = 0; i < this.attrs.length; i += 2) {
182-
const attrName = this.attrs[i];
183-
const attrValue = this.attrs[i + 1] !== '' ? `="${this.attrs[i + 1]}"` : '';
184-
attrs += ` ${attrName}${attrValue}`;
185-
}
186-
187-
return getHtmlTagDefinition(tagName).isVoid ? `<${tagName}${classAttr}${attrs}/>` :
188-
`<${tagName}${classAttr}${attrs}></${tagName}>`;
189-
}
190-
191173
getAttrs(): string[] {
192174
const result: string[] = [];
193175
if (this.classNames.length > 0) {

packages/compiler/test/ml_parser/html_parser_spec.ts

+4-8
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn, humanizeNodes}
754754
const p = parser.parse(
755755
`{messages.length, plural, =0 {<b/>}`, 'TestComp', {tokenizeExpansionForms: true});
756756
expect(humanizeErrors(p.errors)).toEqual([
757-
['b', 'Only void and foreign elements can be self closed "b"', '0:30']
757+
['b', 'Only void, custom and foreign elements can be self closed "b"', '0:30']
758758
]);
759759
});
760760
});
@@ -1117,16 +1117,12 @@ import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn, humanizeNodes}
11171117
const errors = parser.parse('<p />', 'TestComp').errors;
11181118
expect(errors.length).toEqual(1);
11191119
expect(humanizeErrors(errors)).toEqual([
1120-
['p', 'Only void and foreign elements can be self closed "p"', '0:0']
1120+
['p', 'Only void, custom and foreign elements can be self closed "p"', '0:0']
11211121
]);
11221122
});
11231123

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

11321128
it('should also report lexer errors', () => {

packages/compiler/test/selector/selector_spec.ts

-30
Original file line numberDiff line numberDiff line change
@@ -512,36 +512,6 @@ import {el} from '@angular/platform-browser/testing/src/browser_util';
512512
expect(cssSelectors[2].notSelectors[0].classNames).toEqual(['special']);
513513
});
514514
});
515-
516-
describe('CssSelector.getMatchingElementTemplate', () => {
517-
it('should create an element with a tagName, classes, and attributes with the correct casing',
518-
() => {
519-
const selector = CssSelector.parse('Blink.neon.hotpink[Sweet][Dismissable=false]')[0];
520-
const template = selector.getMatchingElementTemplate();
521-
522-
expect(template).toEqual('<Blink class="neon hotpink" Sweet Dismissable="false"></Blink>');
523-
});
524-
525-
it('should create an element without a tag name', () => {
526-
const selector = CssSelector.parse('[fancy]')[0];
527-
const template = selector.getMatchingElementTemplate();
528-
529-
expect(template).toEqual('<div fancy></div>');
530-
});
531-
532-
it('should ignore :not selectors', () => {
533-
const selector = CssSelector.parse('grape:not(.red)')[0];
534-
const template = selector.getMatchingElementTemplate();
535-
536-
expect(template).toEqual('<grape></grape>');
537-
});
538-
539-
it('should support void tags', () => {
540-
const selector = CssSelector.parse('input[fancy]')[0];
541-
const template = selector.getMatchingElementTemplate();
542-
expect(template).toEqual('<input fancy/>');
543-
});
544-
});
545515
}
546516

547517
function getSelectorFor(

0 commit comments

Comments
 (0)