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

Lines changed: 104 additions & 0 deletions
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

Lines changed: 34 additions & 0 deletions
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
}
Lines changed: 13 additions & 0 deletions
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+
}
Lines changed: 17 additions & 0 deletions
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

Lines changed: 19 additions & 8 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 0 additions & 18 deletions
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

Lines changed: 4 additions & 8 deletions
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

Lines changed: 0 additions & 30 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy