diff --git a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts index 5ecb0ac2..1fa301dd 100644 --- a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts +++ b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts @@ -4,5 +4,6 @@ export default interface ISelectorPseudo { name: string; arguments: string | null; selectorItems: SelectorItem[] | null; + selectorGroups?: SelectorItem[][]; nthFunction: ((n: number) => boolean) | null; } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index e93c4416..cb98ae8b 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -12,7 +12,7 @@ import type IHTMLElementTagNameMap from '../config/IHTMLElementTagNameMap.js'; import type ISVGElementTagNameMap from '../config/ISVGElementTagNameMap.js'; import type ICachedQuerySelectorAllItem from '../nodes/node/ICachedQuerySelectorAllResult.js'; import type ICachedQuerySelectorItem from '../nodes/node/ICachedQuerySelectorResult.js'; -import type ICachedMatchesItem from '../nodes/node/ICachedMatchesResult.js'; +import type ICachedResult from '../nodes/node/ICachedResult.js'; import type ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; import type { TGlobalMatchFunction } from './TGlobalMatchFunction.js'; @@ -321,12 +321,17 @@ export default class QuerySelector { * @param [options] Options. * @param [options.scope] Scope. * @param [options.ignoreErrors] Ignores errors. + * @param [options.cachedItem] Owning query cache item for nested matches. * @returns Result. */ public static matches( element: Element, selector: string, - options?: { scope?: Element | Document | DocumentFragment | null; ignoreErrors?: boolean } + options?: { + scope?: Element | Document | DocumentFragment | null; + ignoreErrors?: boolean; + cachedItem?: ICachedResult; + } ): ISelectorMatch | null { const ignoreErrors = options?.ignoreErrors; const window = element[PropertySymbol.window]; @@ -373,7 +378,10 @@ export default class QuerySelector { ); } - const cachedResult = element[PropertySymbol.cache].matches.get(selector); + // Nested matches share the owning query's dependencies, not its result or scope cache. + const cachedResult = options?.cachedItem + ? null + : element[PropertySymbol.cache].matches.get(selector); if (cachedResult) { if (cachedResult.result !== null) { @@ -382,11 +390,13 @@ export default class QuerySelector { element[PropertySymbol.cache].matches.delete(selector); } - const cachedItem: ICachedMatchesItem = { + const cachedItem: ICachedResult = options?.cachedItem || { result: { match: null } }; - element[PropertySymbol.cache].matches.set(selector, cachedItem); + if (!options?.cachedItem) { + element[PropertySymbol.cache].matches.set(selector, cachedItem); + } if (element[PropertySymbol.isConnected]) { // Document is affected for the ":target" selector @@ -422,7 +432,9 @@ export default class QuerySelector { }); if (result) { - cachedItem.result!.match = result; + if (!options?.cachedItem) { + cachedItem.result!.match = result; + } return result; } } @@ -449,7 +461,7 @@ export default class QuerySelector { scope: Element | Document | DocumentFragment | null; element: Element | ShadowRoot; selectorItems: SelectorItem[]; - cachedItem: ICachedMatchesItem; + cachedItem: ICachedResult; ignoreErrors?: boolean; }, previousSelectorItem: SelectorItem | null = null, @@ -462,7 +474,7 @@ export default class QuerySelector { return null; } - const result = selectorItem.match(scope, element, ignoreErrors); + const result = selectorItem.match(scope, element, ignoreErrors, cachedItem); if (result) { if (selectorItems.length === 1) { @@ -561,6 +573,7 @@ export default class QuerySelector { ) { const parentElement = element.parentNode; if (parentElement && parentElement !== element[PropertySymbol.ownerDocument]) { + this.affectsCache(parentElement, cachedItem); return this.matchSelector( { scope, @@ -616,7 +629,7 @@ export default class QuerySelector { this.affectsCache(child, cachedItem); - if (selectorItem.match(scope, child)) { + if (selectorItem.match(scope, child, undefined, cachedItem)) { if (!nextSelectorItem) { if (rootElement !== child) { matched.push({ @@ -741,7 +754,7 @@ export default class QuerySelector { this.affectsCache(child, cachedItem); - if (selectorItem.match(scope, child)) { + if (selectorItem.match(scope, child, undefined, cachedItem)) { if (!nextSelectorItem) { if (rootElement !== child) { return { documentPosition: position, element: child }; diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 3a91bc79..71950290 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -9,6 +9,7 @@ import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.j import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; import type ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; import type { TGlobalMatchFunction } from './TGlobalMatchFunction.js'; +import type ICachedResult from '../nodes/node/ICachedResult.js'; /** * Selector item. @@ -63,12 +64,14 @@ export default class SelectorItem { * @param scope Scope. * @param element HTML element. * @param [ignoreErrors] Ignores errors. + * @param [cachedItem] Owning query cache item. * @returns Result. */ public match( scope: Element | DocumentFragment | null, element: Element | ShadowRoot, - ignoreErrors?: boolean + ignoreErrors?: boolean, + cachedItem?: ICachedResult ): ISelectorMatch | null { let priorityWeight = 0; @@ -76,11 +79,19 @@ export default class SelectorItem { return null; } - element = ( - (element[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode - ? (element).host! - : element) - ); + if (element[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + const host = (element).host; + // Ordinary ancestor conditions must not observe the host across a shadow boundary. + if (!host || !this.canMatchShadowHost()) { + return null; + } + if ( + this.pseudos!.some((pseudo) => pseudo.name === 'host' || pseudo.name === 'host-context') + ) { + element = host; + } + } + element = element; if (!element) { return null; @@ -122,7 +133,7 @@ export default class SelectorItem { // Pseudo match if (this.pseudos) { - const result = this.matchPseudo(scope, element, ignoreErrors); + const result = this.matchPseudo(scope, element, ignoreErrors, cachedItem); if (!result) { return null; } @@ -138,12 +149,14 @@ export default class SelectorItem { * @param scope Scope. * @param element Element. * @param [ignoreErrors] Ignores errors. + * @param [cachedItem] Owning query cache item. * @returns Result. */ private matchPseudo( scope: Element | DocumentFragment | null, element: Element, - ignoreErrors?: boolean + ignoreErrors?: boolean, + cachedItem?: ICachedResult ): ISelectorMatch | null { const parent = element[PropertySymbol.parentNode]; const parentChildren = element[PropertySymbol.parentNode] @@ -202,7 +215,8 @@ export default class SelectorItem { element, parentChildren, pseudo, - ignoreErrors + ignoreErrors, + cachedItem ); if (!selectorMatch) { @@ -223,13 +237,15 @@ export default class SelectorItem { * @param parentChildren Parent children. * @param pseudo Pseudo. * @param [ignoreErrors] Ignores errors. + * @param [cachedItem] Owning query cache item. */ private matchPseudoItem( scope: Element | DocumentFragment | null, element: Element, parentChildren: Element[], pseudo: ISelectorPseudo, - ignoreErrors?: boolean + ignoreErrors?: boolean, + cachedItem?: ICachedResult ): ISelectorMatch | null { const root = this.getRootElement(scope); switch (pseudo.name) { @@ -297,21 +313,25 @@ export default class SelectorItem { ? { priorityWeight: 10 } : null; case 'not': - for (const selectorItem of pseudo.selectorItems!) { - if (selectorItem.match(scope, element)) { - return null; - } - } - return { priorityWeight: 10 }; + return this.globalMatchFunction(element, pseudo.arguments!, { + scope, + ignoreErrors, + cachedItem + }) + ? null + : { priorityWeight: 10 }; case 'nth-child': let nthChildIndex = -1; - if (pseudo.selectorItems?.[0] && !pseudo.selectorItems[0].match(scope, element)) { + if ( + pseudo.selectorItems?.[0] && + !pseudo.selectorItems[0].match(scope, element, ignoreErrors, cachedItem) + ) { return null; } for (let i = 0, max = parentChildren.length; i < max; i++) { if ( !pseudo.selectorItems?.[0] || - pseudo.selectorItems![0].match(scope, parentChildren[i]) + pseudo.selectorItems![0].match(scope, parentChildren[i], ignoreErrors, cachedItem) ) { nthChildIndex++; } @@ -337,13 +357,16 @@ export default class SelectorItem { return null; case 'nth-last-child': let nthLastChildIndex = -1; - if (pseudo.selectorItems?.[0] && !pseudo.selectorItems[0].match(scope, element)) { + if ( + pseudo.selectorItems?.[0] && + !pseudo.selectorItems[0].match(scope, element, ignoreErrors, cachedItem) + ) { return null; } for (let i = parentChildren.length - 1; i >= 0; i--) { if ( !pseudo.selectorItems?.[0] || - pseudo.selectorItems![0].match(scope, parentChildren[i]) + pseudo.selectorItems![0].match(scope, parentChildren[i], ignoreErrors, cachedItem) ) { nthLastChildIndex++; } @@ -374,23 +397,39 @@ export default class SelectorItem { } return element.isConnected && element.id === hash.slice(1) ? { priorityWeight: 10 } : null; case 'is': + if (element[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + return this.globalMatchFunction(element, pseudo.arguments!, { + scope, + ignoreErrors, + cachedItem + }); + } let priorityWeightForIs = 0; if (!pseudo.selectorItems) { return null; } for (const selectorItem of pseudo.selectorItems) { - const match = selectorItem.match(scope, element); + const match = selectorItem.match(scope, element, ignoreErrors, cachedItem); if (match && priorityWeightForIs < match.priorityWeight) { priorityWeightForIs = match.priorityWeight; } } return priorityWeightForIs ? { priorityWeight: priorityWeightForIs } : null; case 'where': + if (element[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + return this.globalMatchFunction(element, pseudo.arguments!, { + scope, + ignoreErrors, + cachedItem + }) + ? { priorityWeight: 0 } + : null; + } if (!pseudo.selectorItems) { return null; } for (const selectorItem of pseudo.selectorItems) { - if (selectorItem.match(scope, element)) { + if (selectorItem.match(scope, element, ignoreErrors, cachedItem)) { return { priorityWeight: 0 }; } } @@ -404,7 +443,7 @@ export default class SelectorItem { return null; } for (const selectorItem of pseudo.selectorItems) { - const match = selectorItem.match(scope, nextSibling); + const match = selectorItem.match(scope, nextSibling, ignoreErrors, cachedItem); if (match && priorityWeightForHas < match.priorityWeight) { priorityWeightForHas = match.priorityWeight; } @@ -412,7 +451,7 @@ export default class SelectorItem { } else if (pseudo.arguments[0] === '>') { for (const selectorItem of pseudo.selectorItems) { for (const child of element[PropertySymbol.elementArray]) { - const match = selectorItem.match(scope, child); + const match = selectorItem.match(scope, child, ignoreErrors, cachedItem); if (match && priorityWeightForHas < match.priorityWeight) { priorityWeightForHas = match.priorityWeight; break; @@ -421,7 +460,13 @@ export default class SelectorItem { } } else { for (const selectorItem of pseudo.selectorItems) { - const match = this.matchChildOfElement(scope, selectorItem, element, ignoreErrors); + const match = this.matchChildOfElement( + scope, + selectorItem, + element, + ignoreErrors, + cachedItem + ); if (match && priorityWeightForHas < match.priorityWeight) { priorityWeightForHas = match.priorityWeight; } @@ -441,6 +486,35 @@ export default class SelectorItem { } } + /** + * Whether this compound selector is allowed to match a featureless shadow host. + * + * See https://drafts.csswg.org/selectors/#featureless. + * + * @returns Whether the selector can target the shadow host. + */ + private canMatchShadowHost(): boolean { + if (this.tagName || this.id || this.classNames || this.attributes || !this.pseudos) { + return false; + } + let hasHostSelector = false; + for (const pseudo of this.pseudos) { + if (pseudo.name === 'host' || pseudo.name === 'host-context') { + hasHostSelector = true; + } else if (pseudo.name === 'is' || pseudo.name === 'where' || pseudo.name === 'not') { + if ( + !pseudo.selectorGroups?.some((group) => group[group.length - 1]?.canMatchShadowHost()) + ) { + return false; + } + hasHostSelector = true; + } else if (pseudo.name !== 'has') { + return false; + } + } + return hasHostSelector; + } + /** * Matches attribute. * @@ -508,20 +582,28 @@ export default class SelectorItem { * @param selectorItem Selector item. * @param element Element. * @param [ignoreErrors] Ignores errors. + * @param [cachedItem] Owning query cache item. * @returns Result. */ private matchChildOfElement( scope: Element | DocumentFragment | null, selectorItem: SelectorItem, element: Element, - ignoreErrors?: boolean + ignoreErrors?: boolean, + cachedItem?: ICachedResult ): { priorityWeight: number } | null { for (const child of element[PropertySymbol.elementArray]) { - const match = selectorItem.match(scope, child, ignoreErrors); + const match = selectorItem.match(scope, child, ignoreErrors, cachedItem); if (match) { return match; } - const childMatch = this.matchChildOfElement(scope, selectorItem, child, ignoreErrors); + const childMatch = this.matchChildOfElement( + scope, + selectorItem, + child, + ignoreErrors, + cachedItem + ); if (childMatch) { return childMatch; } diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts index 60cc9510..aa10a92e 100644 --- a/packages/happy-dom/src/query-selector/SelectorParser.ts +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts @@ -565,22 +565,20 @@ export default class SelectorParser { nthFunction: this.getPseudoNthFunction(args) }; case 'not': - const notSelectorItems = []; - for (const group of this.getSelectorGroups(args)) { - if (group[0]) { - notSelectorItems.push(group[0]); - } - } + // Validate and cache the complete list without truncating its combinators. + const notSelectorGroups = this.getSelectorGroups(args); return { name: lowerName, arguments: args, - selectorItems: notSelectorItems, + selectorItems: null, + selectorGroups: notSelectorGroups, nthFunction: null }; case 'is': case 'where': const selectorItems = []; - for (const group of this.getSelectorGroups(args)) { + const selectorGroups = this.getSelectorGroups(args); + for (const group of selectorGroups) { if (group[0]) { selectorItems.push(group[0]); } @@ -589,6 +587,7 @@ export default class SelectorParser { name: lowerName, arguments: args, selectorItems, + selectorGroups, nthFunction: null }; case 'has': diff --git a/packages/happy-dom/src/query-selector/TGlobalMatchFunction.ts b/packages/happy-dom/src/query-selector/TGlobalMatchFunction.ts index b5decabe..49fdb87e 100644 --- a/packages/happy-dom/src/query-selector/TGlobalMatchFunction.ts +++ b/packages/happy-dom/src/query-selector/TGlobalMatchFunction.ts @@ -2,11 +2,16 @@ import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.j import type Document from '../nodes/document/Document.js'; import type Element from '../nodes/element/Element.js'; import type ISelectorMatch from './ISelectorMatch.js'; +import type ICachedResult from '../nodes/node/ICachedResult.js'; type TGlobalMatchFunction = ( element: Element, selector: string, - options?: { scope?: Element | Document | DocumentFragment | null; ignoreErrors?: boolean } + options?: { + scope?: Element | Document | DocumentFragment | null; + ignoreErrors?: boolean; + cachedItem?: ICachedResult; + } ) => ISelectorMatch | null; export type { TGlobalMatchFunction }; diff --git a/packages/happy-dom/test/query-selector/QuerySelectorNot.test.ts b/packages/happy-dom/test/query-selector/QuerySelectorNot.test.ts new file mode 100644 --- /dev/null +++ b/packages/happy-dom/test/query-selector/QuerySelectorNot.test.ts @@ -0,0 +1,203 @@ +/** Regression coverage for complex selectors inside :not() and their cache dependencies. */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Window from '../../src/window/Window.js'; +import type Document from '../../src/nodes/document/Document.js'; + +describe('Complex :not() selectors', () => { + let window: Window; + let document: Document; + + beforeEach(() => { + window = new Window(); + document = window.document; + document.body.innerHTML = ` +
+ +
+
+ + + + + + `; + }); + + afterEach(async () => { + await window.happyDOM.close(); + }); + + it.each([ + { + selector: '.item:not(.comments *)', + ids: ['before', 'adjacent', 'later'] + }, + { + selector: '.item:not(.comments > .item)', + ids: ['deep', 'before', 'adjacent', 'later'] + }, + { + selector: '.item:not(.marker + .item)', + ids: ['direct', 'deep', 'before', 'later'] + }, + { + selector: '.item:not(.marker ~ .item)', + ids: ['direct', 'deep', 'before'] + } + ])('Matches the complete selector "$selector" through public APIs.', ({ selector, ids }) => { + expect(Array.from(document.querySelectorAll(selector), (element) => element.id)).toEqual(ids); + expect(document.querySelector(selector)?.id).toBe(ids[0]); + for (const element of document.querySelectorAll('.item')) { + const expected = ids.includes(element.id); + expect(element.matches(selector)).toBe(expected); + expect(element.matches(selector)).toBe(expected); + expect(element.closest(selector)).toBe(expected ? element : null); + } + }); + + it('Preserves an element with the excluded ancestor class on itself.', () => { + const comments = document.getElementById('comments')!; + comments.classList.add('item'); + expect(comments.matches('.item:not(.comments *)')).toBe(true); + expect(document.getElementById('direct')!.matches('.item:not(.comments *)')).toBe(false); + }); + + it('Checks all complex branches of an exclusion list.', () => { + expect( + Array.from( + document.querySelectorAll('.item:not(.comments *, .marker + .item)'), + (element) => element.id + ) + ).toEqual(['before', 'later']); + }); + + it('Supports nested negation.', () => { + expect( + Array.from(document.querySelectorAll('.item:not(:not(.comments *))'), (element) => element.id) + ).toEqual(['direct', 'deep']); + }); + + it('Supports consecutive exclusions.', () => { + expect( + Array.from( + document.querySelectorAll('.item:not(.comments *):not(#adjacent)'), + (element) => element.id + ) + ).toEqual(['before', 'later']); + }); + + it.each(['is', 'where'])('Supports negation nested inside :%s().', (name) => { + expect( + Array.from( + document.querySelectorAll(`:${name}(.item:not(.comments *))`), + (element) => element.id + ) + ).toEqual(['before', 'adjacent', 'later']); + }); + + it('Preserves the query scope when matching an exclusion.', () => { + const comments = document.getElementById('comments')!; + const nested = document.getElementById('nested')!; + const selector = '.item:not(:scope > .item)'; + expect(Array.from(comments.querySelectorAll(selector), (element) => element.id)).toEqual([ + 'deep' + ]); + expect(Array.from(nested.querySelectorAll(selector), (element) => element.id)).toEqual([]); + expect(document.getElementById('direct')!.matches(selector)).toBe(true); + }); + + it('Invalidates a negated match when an ancestor changes.', () => { + const comments = document.getElementById('comments')!; + const element = document.getElementById('direct')!; + const selector = '.item:not(.comments *)'; + expect(element.matches(selector)).toBe(false); + comments.classList.remove('comments'); + expect(element.matches(selector)).toBe(true); + comments.classList.add('comments'); + expect(element.matches(selector)).toBe(false); + }); + + it('Tracks ancestors beyond the parent when the argument initially does not match.', () => { + const comments = document.getElementById('comments')!; + const element = document.getElementById('deep')!; + const selector = '.item:not(.excluded *)'; + expect(element.matches(selector)).toBe(true); + comments.classList.add('excluded'); + expect(element.matches(selector)).toBe(false); + comments.classList.remove('excluded'); + expect(element.matches(selector)).toBe(true); + }); + + it.each(['+', '~'])('Tracks changes to a preceding sibling for %s.', (combinator) => { + const marker = document.getElementById('marker')!; + const element = document.getElementById('adjacent')!; + const selector = `.item:not(.marker ${combinator} .item)`; + expect(element.matches(selector)).toBe(false); + marker.className = ''; + expect(element.matches(selector)).toBe(true); + marker.className = 'marker'; + expect(element.matches(selector)).toBe(false); + }); + + it('Invalidates the owning query result when an ancestor outside its root changes.', () => { + const comments = document.getElementById('comments')!; + const nested = document.getElementById('nested')!; + const selector = '.item:not(.comments *)'; + expect(nested.querySelector(selector)).toBe(null); + expect(Array.from(nested.querySelectorAll(selector))).toEqual([]); + comments.className = ''; + expect(nested.querySelector(selector)?.id).toBe('deep'); + expect(Array.from(nested.querySelectorAll(selector), (element) => element.id)).toEqual([ + 'deep' + ]); + }); + + it.each(['is', 'where'])('Propagates cache dependencies through :%s().', (name) => { + const comments = document.getElementById('comments')!; + const element = document.getElementById('deep')!; + const selector = `:${name}(.item:not(.excluded *))`; + expect(element.matches(selector)).toBe(true); + comments.classList.add('excluded'); + expect(element.matches(selector)).toBe(false); + }); + + it('Applies the rule to computed styles and refreshes it after an ancestor changes.', () => { + const style = document.createElement('style'); + style.textContent = '.item { display: block; } .item:not(.comments *) { display: none; }'; + document.head.append(style); + const element = document.getElementById('deep')!; + const comments = document.getElementById('comments')!; + expect(window.getComputedStyle(element).display).toBe('block'); + expect(window.getComputedStyle(document.getElementById('before')!).display).toBe('none'); + comments.className = ''; + expect(window.getComputedStyle(element).display).toBe('none'); + comments.className = 'comments'; + expect(window.getComputedStyle(element).display).toBe('block'); + }); + + it('Still rejects a missing exclusion argument.', () => { + expect(() => document.querySelectorAll('.item:not()')).toThrow(window.DOMException); + }); + + it.each([ + { argument: '.comments *', matches: true }, + { argument: ':not(.missing) .item', matches: true }, + { argument: ':host .item', matches: false }, + { argument: ':is(:host) .item', matches: false }, + { argument: ':where(:host) .item', matches: false }, + { argument: ':not(:not(:host)) .item', matches: false }, + { argument: ':is(.comments, :host(.missing)) .item', matches: true }, + { argument: ':not(:host(.missing)) .item', matches: false } + ])('Respects the shadow boundary in :not($argument).', ({ argument, matches }) => { + const host = document.createElement('div'); + host.className = 'comments'; + document.body.append(host); + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = ''; + const element = root.getElementById('shadow-item')!; + const selector = `.item:not(${argument})`; + expect(element.matches(selector)).toBe(matches); + expect(Array.from(root.querySelectorAll(selector))).toEqual(matches ? [element] : []); + }); +});