From b8b7e8a8678e9b0cd7a02e8cdea3908c18f87c75 Mon Sep 17 00:00:00 2001 From: Slava Leleka Date: Fri, 14 Oct 2022 12:13:08 +0300 Subject: [PATCH] AG-16951 allow has/is/where inside has Squashed commit of the following: commit 9752adcfcc7f47ed8c4648ebcbaf35d2bcd587e1 Author: Slava Leleka Date: Thu Oct 13 17:36:28 2022 +0300 add has(has) tests commit 96d0cfdd9f6ba202617475432b35a65f295606c1 Author: Slava Leleka Date: Thu Oct 13 16:30:37 2022 +0300 improve readme about has pseudo-class commit 53e16449dfdfafe9550e9c1016f4e47f5a360e26 Author: Slava Leleka Date: Thu Oct 13 16:29:43 2022 +0300 allow has/is/where inside has --- README.md | 19 ++-- src/common/constants.ts | 1 - src/selector/parser.ts | 13 --- test/selector/parser.test.ts | 146 +++++++++++++++++++++++++++--- test/selector/query-jsdom.test.ts | 19 ++-- 5 files changed, 151 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 45634423..b541a072 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ The idea of extended capabilities is an opportunity to match DOM elements with s ### Pseudo-class `:has()` -Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/TR/selectors-4/#relational). Unfortunately, it is not yet widely [supported by browsers](https://developer.mozilla.org/en-US/docs/Web/CSS/:has#browser_compatibility). +Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/TR/selectors-4/#relational). Unfortunately, it is not yet [supported by all popular browsers](https://caniuse.com/css-has). -> Synonyms `:-abp-has` and `:if` are supported for better compatibility. +> Rules with `:has()` pseudo-class should use [native implementation of `:has()`]() if rules use `##` marker and it is possible, i.e. with no other extended pseudo-classes inside. To force ExtendedCss applying of rules with `:has()`, use `#?#`/`#$?#` marker obviously. + +> Synonyms `:-abp-has` and `:if` are supported by ExtendedCss for better compatibility. **Syntax** @@ -63,12 +65,15 @@ Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/T Pseudo-class `:has()` selects the `target` elements that includes the elements that fit to the `selector`. Also `selector` can start with a combinator. Selector list can be set in `selector` as well. - **Limitations** + **Limitations and notes** + +> Usage of `:has()` pseudo-class is [restricted for some cases (2, 3)](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54): +> - disallow `:has()` inside the pseudos accepting only compound selectors; +> - disallow `:has()` after regular pseudo-elements. + +> Native `:has()` pseudo-class does not allow `:has()`, `:is()`, `:where()` inside `:has()` argument to avoid increasing the `:has()` invalidation complexity ([case 1](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54)). But ExtendedCss did not have such limitation earlier and filter lists already contain such rules, so we will not add this limitation in ExtendedCss and allow to use `:has()` inside `:has()` as it was possible before. To use it, just force ExtendedCss usage by setting `#?#`/`#$?#` rule marker. -> Usage of `:has()` pseudo-class is [restricted for some cases](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54): -> 1. Disallow `:has()`, `:is()`, `:where()` inside `:has()` argument to avoid increasing the :has() invalidation complexity. -> 2. Disallow `:has()` inside the pseudos accepting only compound selectors. -> 3. Disallow `:has()` after regular pseudo-elements. +> Native implementation does not allow any usage of `:scope` inside `:has()` argument ([[1]](https://github.com/w3c/csswg-drafts/issues/7211), [[2]](https://github.com/w3c/csswg-drafts/issues/6399)). Still there some such rules in filter lists: `div:has(:scope > a)` which we will continue to support simply converting them to `div:has(> a)` as it was earlier. **Examples** diff --git a/src/common/constants.ts b/src/common/constants.ts index 8d7c6052..f05b5feb 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -175,7 +175,6 @@ export const SUPPORTED_PSEUDO_CLASSES = [ */ export const REGULAR_PSEUDO_CLASSES = { SCOPE: 'scope', - WHERE: 'where', }; /** diff --git a/src/selector/parser.ts b/src/selector/parser.ts index d4a4d32d..07ee476e 100644 --- a/src/selector/parser.ts +++ b/src/selector/parser.ts @@ -44,7 +44,6 @@ import { IS_PSEUDO_CLASS_MARKER, NOT_PSEUDO_CLASS_MARKER, REMOVE_PSEUDO_MARKER, - REGULAR_PSEUDO_CLASSES, REGULAR_PSEUDO_ELEMENTS, UPWARD_PSEUDO_CLASS_MARKER, NTH_ANCESTOR_PSEUDO_CLASS_MARKER, @@ -708,18 +707,6 @@ export const parse = (selector: string): AnySelectorNodeInterface => { // is covered by 'bufferNode === null' above at start of COLON checking updateBufferNode(context, ASTERISK); } - // Disallow :has(), :is(), :where() inside :has() argument - // to avoid increasing the :has() invalidation complexity - // https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54 [1] - if (context.extendedPseudoNamesStack.length > 0 - // check the last extended pseudo-class name from context - && HAS_PSEUDO_CLASS_MARKERS.includes(getLast(context.extendedPseudoNamesStack)) - // and check the processing pseudo-class - && (HAS_PSEUDO_CLASS_MARKERS.includes(nextTokenValue) - || nextTokenValue === IS_PSEUDO_CLASS_MARKER - || nextTokenValue === REGULAR_PSEUDO_CLASSES.WHERE)) { - throw new Error(`Usage of :${nextTokenValue} pseudo-class is not allowed inside upper :has`); // eslint-disable-line max-len - } handleNextTokenOnColon(context, selector, tokenValue, nextTokenValue, nextToNextTokenValue); } if (bufferNode?.type === NodeType.Selector) { diff --git a/test/selector/parser.test.ts b/test/selector/parser.test.ts index bf8d1b5d..468a32df 100644 --- a/test/selector/parser.test.ts +++ b/test/selector/parser.test.ts @@ -922,6 +922,98 @@ describe('combined extended selectors', () => { expect(parse(actual)).toEqual(expected); }); + it('has(has)', () => { + const actual = 'div:has(.banner:has(> a > img))'; + const expected = { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('div'), + { + type: NodeType.ExtendedSelector, + children: [ + { + type: NodeType.RelativePseudoClass, + name: 'has', + children: [ + { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('.banner'), + getRelativeExtendedWithSingleRegular('has', '> a > img'), + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(parse(actual)).toEqual(expected); + }); + + it('has(has(contains))', () => { + const actual = 'div:has(.banner:has(> span:contains(inner text)))'; + const expected = { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('div'), + { + type: NodeType.ExtendedSelector, + children: [ + { + type: NodeType.RelativePseudoClass, + name: 'has', + children: [ + { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('.banner'), + { + type: NodeType.ExtendedSelector, + children: [ + { + type: NodeType.RelativePseudoClass, + name: 'has', + children: [ + getSingleSelectorAstWithAnyChildren([ + { isRegular: true, value: '> span' }, + { isAbsolute: true, name: 'contains', value: 'inner text' }, // eslint-disable-line max-len + ]), + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(parse(actual)).toEqual(expected); + }); + it('is(selector list) contains', () => { const actual = '#__next > :is(.header, .footer):contains(ads)'; const expected = { @@ -1723,19 +1815,6 @@ describe('combined selectors', () => { describe('has pseudo-class limitation', () => { const toThrowInputs = [ - // no :has, :is, :where inside :has - { - selector: 'banner:has(> div:has(> img))', - error: 'Usage of :has pseudo-class is not allowed inside upper :has', - }, - { - selector: 'banner:has(> div:is(> img))', - error: 'Usage of :is pseudo-class is not allowed inside upper :has', - }, - { - selector: 'banner:has(> div:where(> img))', - error: 'Usage of :where pseudo-class is not allowed inside upper :has', - }, // no :has inside regular pseudos { selector: '::slotted(:has(.a))', @@ -2069,6 +2148,47 @@ describe('combined selectors', () => { ]; test.each(testsInputs)('%s', (input) => expectSingleSelectorAstWithAnyChildren(input)); + it('has(has) + has', () => { + const actual = 'div:has(> .banner:has(> a > img)) + .ad:has(> img)'; + const expected = { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('div'), + { + type: NodeType.ExtendedSelector, + children: [ + { + type: NodeType.RelativePseudoClass, + name: 'has', + children: [ + { + type: NodeType.SelectorList, + children: [ + { + type: NodeType.Selector, + children: [ + getRegularSelector('> .banner'), + getRelativeExtendedWithSingleRegular('has', '> a > img'), + ], + }, + ], + }, + ], + }, + ], + }, + getRegularSelector('+ .ad'), + getRelativeExtendedWithSingleRegular('has', '> img'), + ], + }, + ], + }; + expect(parse(actual)).toEqual(expected); + }); + it('not > has(not > regular)', () => { // eslint-disable-next-line max-len const actual = 'body > div:not([class]) > div[class]:has(> div:not([class]) > .branch-journeys-top a[target="_blank"][href^="/policy/"])'; diff --git a/test/selector/query-jsdom.test.ts b/test/selector/query-jsdom.test.ts index 83a42950..fcae54b1 100644 --- a/test/selector/query-jsdom.test.ts +++ b/test/selector/query-jsdom.test.ts @@ -1265,6 +1265,12 @@ describe('combined pseudo-classes', () => { { actual: 'div[id^="inn"][class]:has(p:contains(inner))', expected: 'div#inner' }, // has(contains) has contains { actual: '#root div:has(:contains(text)):has(#paragraph):contains(inner paragraph)', expected: '#parent' }, // eslint-disable-line max-len + // has(has) + { actual: '#parent div:has(div:has(> p))', expected: '#child' }, + // has(has contains) + { actual: '#parent div:has(div:has(> p):contains(inner span text))', expected: '#child' }, + // has(has(contains)) + { actual: '#parent div:has(div:has(> p:contains(inner)))', expected: '#child' }, // matches-attr matches-attr upward { actual: '#root *[id^="p"][random] > *:matches-attr("/class/"="/base/"):matches-attr("/level$/"="/^[0-9]$/"):upward(1)', expected: '#parent' }, // eslint-disable-line max-len // matches-attr contains xpath @@ -1383,19 +1389,6 @@ describe('combined pseudo-classes', () => { describe('has limitation', () => { const toThrowInputs = [ - // no :has, :is, :where inside :has - { - selector: 'banner:has(> div:has(> img))', - error: 'Usage of :has pseudo-class is not allowed inside upper :has', - }, - { - selector: 'banner:has(> div:is(> img))', - error: 'Usage of :is pseudo-class is not allowed inside upper :has', - }, - { - selector: 'banner:has(> div:where(> img))', - error: 'Usage of :where pseudo-class is not allowed inside upper :has', - }, // no :has inside regular pseudos { selector: '::slotted(:has(.a))',