Skip to content

Commit 7767aa6

Browse files
crisbetoNothingEverHappens
authored andcommitted
fix(compiler): allow more characters in square-bracketed attribute names (#62742)
Currently the HTML parser will stop parsing as soon as it hits an end character in the name of an attribute (e.g. `/` or `>`). This ends up being problematic with some third-party packages like Tailwind which uses a wider range of characters for its class names. While the characters are fine when inside the `class` attribute, our current parser behavior prevents users from setting those classes conditionally through `[class.]` bindings. These changes adjust the parser to handle such cases. Fixes #61671. PR Close #62742
1 parent 74d99ed commit 7767aa6

File tree

11 files changed

+345
-0
lines changed

11 files changed

+345
-0
lines changed

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/class_bindings/GOLDEN_PARTIAL.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,72 @@ export declare class MyComponent {
272272
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "my-component", never, {}, {}, never, never, true, never>;
273273
}
274274

275+
/****************************************************************************************************
276+
* PARTIAL FILE: class_binding_special_chars.js
277+
****************************************************************************************************/
278+
import { Component } from '@angular/core';
279+
import * as i0 from "@angular/core";
280+
export class MyComponent {
281+
constructor() {
282+
this.expr = true;
283+
}
284+
}
285+
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
286+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
287+
<div [class.text-primary/80]="expr"
288+
[class.data-active:text-green-300/80]="expr"
289+
[class.data-[size='large']:p-8]="expr"></div>`, isInline: true });
290+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
291+
type: Component,
292+
args: [{
293+
template: `
294+
<div [class.text-primary/80]="expr"
295+
[class.data-active:text-green-300/80]="expr"
296+
[class.data-[size='large']:p-8]="expr"></div>`,
297+
}]
298+
}] });
299+
300+
/****************************************************************************************************
301+
* PARTIAL FILE: class_binding_special_chars.d.ts
302+
****************************************************************************************************/
303+
import * as i0 from "@angular/core";
304+
export declare class MyComponent {
305+
expr: boolean;
306+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
307+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
308+
}
309+
310+
/****************************************************************************************************
311+
* PARTIAL FILE: host_class_binding_special_chars.js
312+
****************************************************************************************************/
313+
import { Component } from '@angular/core';
314+
import * as i0 from "@angular/core";
315+
export class MyComponent {
316+
constructor() {
317+
this.expr = true;
318+
}
319+
}
320+
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
321+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", host: { properties: { "class.text-primary/80": "expr", "class.data-active:text-green-300/80": "expr", "class.data-[size='large'": "expr" } }, ngImport: i0, template: ``, isInline: true });
322+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
323+
type: Component,
324+
args: [{
325+
template: ``,
326+
host: {
327+
'[class.text-primary/80]': 'expr',
328+
'[class.data-active:text-green-300/80]': 'expr',
329+
"[class.data-[size='large']:p-8]": 'expr',
330+
},
331+
}]
332+
}] });
333+
334+
/****************************************************************************************************
335+
* PARTIAL FILE: host_class_binding_special_chars.d.ts
336+
****************************************************************************************************/
337+
import * as i0 from "@angular/core";
338+
export declare class MyComponent {
339+
expr: boolean;
340+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
341+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
342+
}
343+

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/class_bindings/TEST_CASES.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@
8787
"failureMessage": "Incorrect template"
8888
}
8989
]
90+
},
91+
{
92+
"description": "should handle bindings to classes with special characters in a template",
93+
"inputFiles": [
94+
"class_binding_special_chars.ts"
95+
],
96+
"expectations": [
97+
{
98+
"failureMessage": "Incorrect template",
99+
"files": [
100+
"class_binding_special_chars.js"
101+
]
102+
}
103+
]
104+
},
105+
{
106+
"description": "should handle bindings to classes with special characters in a host context",
107+
"inputFiles": [
108+
"host_class_binding_special_chars.ts"
109+
],
110+
"expectations": [
111+
{
112+
"failureMessage": "Incorrect template",
113+
"files": [
114+
"host_class_binding_special_chars.js"
115+
]
116+
}
117+
]
90118
}
91119
]
92120
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
$r3$.ɵɵdefineComponent({
2+
3+
decls: 1,
4+
vars: 6,
5+
template: function MyComponent_Template(rf, ctx) {
6+
if (rf & 1) {
7+
$r3$.ɵɵdomElement(0, "div");
8+
}
9+
if (rf & 2) {
10+
$r3$.ɵɵclassProp("text-primary/80", ctx.expr)("data-active:text-green-300/80", ctx.expr)("data-[size='large']:p-8", ctx.expr);
11+
}
12+
},
13+
14+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: `
5+
<div [class.text-primary/80]="expr"
6+
[class.data-active:text-green-300/80]="expr"
7+
[class.data-[size='large']:p-8]="expr"></div>`,
8+
})
9+
export class MyComponent {
10+
expr = true;
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
$r3$.ɵɵdefineComponent({
2+
3+
hostVars: 6,
4+
hostBindings: function MyComponent_HostBindings(rf, ctx) {
5+
if (rf & 2) {
6+
$r3$.ɵɵclassProp("text-primary/80", ctx.expr)("data-active:text-green-300/80", ctx.expr)("data-[size='large'", ctx.expr);
7+
}
8+
},
9+
10+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: ``,
5+
host: {
6+
'[class.text-primary/80]': 'expr',
7+
'[class.data-active:text-green-300/80]': 'expr',
8+
"[class.data-[size='large']:p-8]": 'expr',
9+
},
10+
})
11+
export class MyComponent {
12+
expr = true;
13+
}

packages/compiler/src/ml_parser/lexer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,22 @@ class _Tokenizer {
962962
}
963963
return isNameEnd(code);
964964
};
965+
} else if (attrNameStart === chars.$LBRACKET) {
966+
let openBrackets = 0;
967+
968+
// Be more permissive for which characters are allowed inside square-bracketed attributes,
969+
// because they usually end up being bound as attribute values. Some third-party packages
970+
// like Tailwind take advantage of this.
971+
nameEndPredicate = (code: number) => {
972+
if (code === chars.$LBRACKET) {
973+
openBrackets++;
974+
} else if (code === chars.$RBRACKET) {
975+
openBrackets--;
976+
}
977+
// Only check for name-ending characters if the brackets are balanced or mismatched.
978+
// Also interrupt the matching on new lines.
979+
return openBrackets <= 0 ? isNameEnd(code) : chars.isNewLine(code);
980+
};
965981
} else {
966982
nameEndPredicate = isNameEnd;
967983
}

packages/compiler/test/ml_parser/html_parser_spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,25 @@ describe('HtmlParser', () => {
502502
]);
503503
});
504504
});
505+
506+
it('should parse square-bracketed attributes more permissively', () => {
507+
expect(
508+
humanizeDom(
509+
parser.parse(
510+
`<foo [class.text-primary/80]="expr" ` +
511+
`[class.data-active:text-green-300/80]="expr2" ` +
512+
`[class.data-[size='large']:p-8] = "expr3" some-attr/>`,
513+
'TestComp',
514+
),
515+
),
516+
).toEqual([
517+
[html.Element, 'foo', 0, '#selfClosing'],
518+
[html.Attribute, '[class.text-primary/80]', 'expr', ['expr']],
519+
[html.Attribute, '[class.data-active:text-green-300/80]', 'expr2', ['expr2']],
520+
[html.Attribute, "[class.data-[size='large']:p-8]", 'expr3', ['expr3']],
521+
[html.Attribute, 'some-attr', ''],
522+
]);
523+
});
505524
});
506525

507526
describe('comments', () => {

packages/compiler/test/ml_parser/lexer_spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1965,6 +1965,107 @@ describe('HtmlLexer', () => {
19651965
['Unexpected character "EOF"', '0:8'],
19661966
]);
19671967
});
1968+
1969+
it('should permit more characters in square-bracketed attributes', () => {
1970+
expect(tokenizeAndHumanizeParts('<foo [class.text-primary/80]="expr"/>')).toEqual([
1971+
[TokenType.TAG_OPEN_START, '', 'foo'],
1972+
[TokenType.ATTR_NAME, '', '[class.text-primary/80]'],
1973+
[TokenType.ATTR_QUOTE, '"'],
1974+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
1975+
[TokenType.ATTR_QUOTE, '"'],
1976+
[TokenType.TAG_OPEN_END_VOID],
1977+
[TokenType.EOF],
1978+
]);
1979+
expect(
1980+
tokenizeAndHumanizeParts('<foo [class.data-active:text-green-300/80]="expr"/>'),
1981+
).toEqual([
1982+
[TokenType.TAG_OPEN_START, '', 'foo'],
1983+
[TokenType.ATTR_NAME, '', '[class.data-active:text-green-300/80]'],
1984+
[TokenType.ATTR_QUOTE, '"'],
1985+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
1986+
[TokenType.ATTR_QUOTE, '"'],
1987+
[TokenType.TAG_OPEN_END_VOID],
1988+
[TokenType.EOF],
1989+
]);
1990+
expect(tokenizeAndHumanizeParts(`<foo [class.data-[size='large']:p-8] = "expr"/>`)).toEqual([
1991+
[TokenType.TAG_OPEN_START, '', 'foo'],
1992+
[TokenType.ATTR_NAME, '', "[class.data-[size='large']:p-8]"],
1993+
[TokenType.ATTR_QUOTE, '"'],
1994+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
1995+
[TokenType.ATTR_QUOTE, '"'],
1996+
[TokenType.TAG_OPEN_END_VOID],
1997+
[TokenType.EOF],
1998+
]);
1999+
expect(tokenizeAndHumanizeParts(`<foo [class.data-[size='large']:p-8]/>`)).toEqual([
2000+
[TokenType.TAG_OPEN_START, '', 'foo'],
2001+
[TokenType.ATTR_NAME, '', "[class.data-[size='large']:p-8]"],
2002+
[TokenType.TAG_OPEN_END_VOID],
2003+
[TokenType.EOF],
2004+
]);
2005+
expect(
2006+
tokenizeAndHumanizeParts(`<foo [class.data-[size='hello white space']]="expr"/>`),
2007+
).toEqual([
2008+
[TokenType.TAG_OPEN_START, '', 'foo'],
2009+
[TokenType.ATTR_NAME, '', "[class.data-[size='hello white space']]"],
2010+
[TokenType.ATTR_QUOTE, '"'],
2011+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
2012+
[TokenType.ATTR_QUOTE, '"'],
2013+
[TokenType.TAG_OPEN_END_VOID],
2014+
[TokenType.EOF],
2015+
]);
2016+
expect(
2017+
tokenizeAndHumanizeParts(
2018+
`<foo [class.text-primary/80]="expr" ` +
2019+
`[class.data-active:text-green-300/80]="expr2" ` +
2020+
`[class.data-[size='large']:p-8] = "expr3" some-attr/>`,
2021+
),
2022+
).toEqual([
2023+
[TokenType.TAG_OPEN_START, '', 'foo'],
2024+
[TokenType.ATTR_NAME, '', '[class.text-primary/80]'],
2025+
[TokenType.ATTR_QUOTE, '"'],
2026+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
2027+
[TokenType.ATTR_QUOTE, '"'],
2028+
[TokenType.ATTR_NAME, '', '[class.data-active:text-green-300/80]'],
2029+
[TokenType.ATTR_QUOTE, '"'],
2030+
[TokenType.ATTR_VALUE_TEXT, 'expr2'],
2031+
[TokenType.ATTR_QUOTE, '"'],
2032+
[TokenType.ATTR_NAME, '', `[class.data-[size='large']:p-8]`],
2033+
[TokenType.ATTR_QUOTE, '"'],
2034+
[TokenType.ATTR_VALUE_TEXT, 'expr3'],
2035+
[TokenType.ATTR_QUOTE, '"'],
2036+
[TokenType.ATTR_NAME, '', `some-attr`],
2037+
[TokenType.TAG_OPEN_END_VOID],
2038+
[TokenType.EOF],
2039+
]);
2040+
});
2041+
2042+
it('should allow mismatched square brackets in attribute name', () => {
2043+
expect(tokenizeAndHumanizeParts(`<foo [class.a]b]c]="expr"/>`)).toEqual([
2044+
[TokenType.TAG_OPEN_START, '', 'foo'],
2045+
[TokenType.ATTR_NAME, '', '[class.a]b]c]'],
2046+
[TokenType.ATTR_QUOTE, '"'],
2047+
[TokenType.ATTR_VALUE_TEXT, 'expr'],
2048+
[TokenType.ATTR_QUOTE, '"'],
2049+
[TokenType.TAG_OPEN_END_VOID],
2050+
[TokenType.EOF],
2051+
]);
2052+
expect(tokenizeAndHumanizeParts(`<foo [class.a[]][[]]b]][c]/>`)).toEqual([
2053+
[TokenType.TAG_OPEN_START, '', 'foo'],
2054+
[TokenType.ATTR_NAME, '', '[class.a[]][[]]b]][c]'],
2055+
[TokenType.TAG_OPEN_END_VOID],
2056+
[TokenType.EOF],
2057+
]);
2058+
});
2059+
2060+
it('should stop permissive parsing of square brackets on new line', () => {
2061+
expect(tokenizeAndHumanizeParts(`<foo [class.text-\nprimary/80]="expr"/>`)).toEqual([
2062+
[TokenType.INCOMPLETE_TAG_OPEN, '', 'foo'],
2063+
[TokenType.ATTR_NAME, '', '[class.text-'],
2064+
[TokenType.ATTR_NAME, '', 'primary'],
2065+
[TokenType.TEXT, '80]="expr"/>'],
2066+
[TokenType.EOF],
2067+
]);
2068+
});
19682069
});
19692070

19702071
describe('closing tags', () => {

packages/compiler/test/render3/r3_template_transform_spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,20 @@ describe('R3 template transform', () => {
375375
['BoundAttribute', BindingType.Style, 'someStyle', 'v'],
376376
]);
377377
});
378+
379+
it('should parse class bindings with various characters', () => {
380+
expectFromHtml(
381+
`<foo [class.text-primary/80]="expr" ` +
382+
`[class.data-active:text-green-300/80]="expr2" ` +
383+
`[class.data-[size='large']:p-8] = "expr3" some-attr/>`,
384+
).toEqual([
385+
['Element', 'foo', '#selfClosing'],
386+
['TextAttribute', 'some-attr', ''],
387+
['BoundAttribute', BindingType.Class, 'text-primary/80', 'expr'],
388+
['BoundAttribute', BindingType.Class, 'data-active:text-green-300/80', 'expr2'],
389+
['BoundAttribute', BindingType.Class, `data-[size='large']:p-8`, 'expr3'],
390+
]);
391+
});
378392
});
379393

380394
describe('animation bindings', () => {

0 commit comments

Comments
 (0)