From bc871b720ad7d8c5b8a1a221908cbb95f9110da6 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 08:04:39 -0400 Subject: [PATCH 1/2] fix(core): boolean properties and class substring operators in attribute selectors - `[attr]` no longer matches a boolean property that is false. Every view has `hidden` (false by default), so `[hidden] { display: none }` from a CSS reset (Tailwind's preflight, Bootstrap's Reboot) hid the whole app. A boolean is now present only while true, like an HTML boolean attribute; `[hidden=false]` still compares by value. - `[class*=x]`, `[class^=x]`, `[class$=x]`, `[class=x]` and `[class|=x]` were all turned into the class selector `.x`, ignoring the operator. Only `.x` and `[class~=x]` are class matches now; the other operators match the class attribute's text. --- packages/core/ui/styling/css-selector.spec.ts | 35 +++++++++++++++++++ packages/core/ui/styling/css-selector.ts | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/core/ui/styling/css-selector.spec.ts b/packages/core/ui/styling/css-selector.spec.ts index 63826b9c35..00a847eb9c 100644 --- a/packages/core/ui/styling/css-selector.spec.ts +++ b/packages/core/ui/styling/css-selector.spec.ts @@ -360,6 +360,41 @@ describe('css-selector', () => { } } + it('matches a boolean property only while it is true, like an HTML boolean attribute', () => { + const rule = createOne(`[hidden] { display: none; }`); + + expect(rule.selectors[0].match({ cssType: 'label', hidden: false })).toBe(false); + expect(rule.selectors[0].match({ cssType: 'label', hidden: true })).toBe(true); + }); + + it('still compares a false boolean property by value', () => { + const rule = createOne(`[hidden=false] { color: red; }`); + + expect(rule.selectors[0].match({ cssType: 'label', hidden: false })).toBe(true); + expect(rule.selectors[0].match({ cssType: 'label', hidden: true })).toBe(false); + }); + + it('matches substring operators against the class attribute', () => { + const contains = createOne(`[class*='btn-'] { color: red; }`).selectors[0]; + const prefix = createOne(`[class^='col'] { color: red; }`).selectors[0]; + const node = (className: string) => { cssType: 'label', class: className, cssClasses: new Set(className.split(' ')) }; + + expect(contains.match(node('btn btn-primary'))).toBe(true); + expect(contains.match(node('btn'))).toBe(false); + expect(contains.match(node('btn-'))).toBe(true); + expect(prefix.match(node('col-6 row'))).toBe(true); + expect(prefix.match(node('row col-6'))).toBe(false); + }); + + it('keeps class selectors and [class~=x] as class matches', () => { + const node = { cssType: 'label', class: 'a b', cssClasses: new Set(['a', 'b']) }; + + expect(createOne(`.b { color: red; }`).selectors[0].match(node)).toBe(true); + expect(createOne(`[class~=b] { color: red; }`).selectors[0].match(node)).toBe(true); + expect(createOne(`[class=b] { color: red; }`).selectors[0].match(node)).toBe(false); + expect(createOne(`[class='a b'] { color: red; }`).selectors[0].match(node)).toBe(true); + }); + it('does not match a node that does not know the attribute', () => { const rule = createOne(`.title[_ngcontent-c7] { color: red; }`); const node = { cssType: 'label', cssClasses: new Set(['title']), '_ngcontent-c3': '' }; diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index d09a3b3add..63d8f64090 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -381,7 +381,9 @@ export class AttributeSelector extends SimpleSelector { let attr = node[this.attribute]; if (this.test === 'exists') { - return !isNullOrUndefined(attr); + // A boolean property is present only while true, like an HTML boolean attribute: + // every view has `hidden`, and `[hidden]` must not match the default `false`. + return !isNullOrUndefined(attr) && attr !== false; } if (!this.value) { @@ -983,7 +985,8 @@ function appendDeclaration(declarations: Declaration[], decl: ReworkCSS.Declarat function createSimpleSelectorFromAst(ast: CSSWhatSelector): SimpleSelector { if (ast.type === 'attribute') { - if (ast.name === 'class') { + // `.x` parses as `[class~=x]`; other operators on class match the class attribute's text. + if (ast.name === 'class' && ast.action === 'element' && ast.ignoreCase !== true) { return new ClassSelector(ast.value); } From aab87c4543f807c1615d6c6360aea92604238bd7 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 16:26:04 -0400 Subject: [PATCH 2/2] chore(core): drop comments --- packages/core/ui/styling/css-selector.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index 63d8f64090..89f2ca17d3 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -381,8 +381,6 @@ export class AttributeSelector extends SimpleSelector { let attr = node[this.attribute]; if (this.test === 'exists') { - // A boolean property is present only while true, like an HTML boolean attribute: - // every view has `hidden`, and `[hidden]` must not match the default `false`. return !isNullOrUndefined(attr) && attr !== false; } @@ -985,7 +983,6 @@ function appendDeclaration(declarations: Declaration[], decl: ReworkCSS.Declarat function createSimpleSelectorFromAst(ast: CSSWhatSelector): SimpleSelector { if (ast.type === 'attribute') { - // `.x` parses as `[class~=x]`; other operators on class match the class attribute's text. if (ast.name === 'class' && ast.action === 'element' && ast.ignoreCase !== true) { return new ClassSelector(ast.value); }