BenItBuhner
**Describe the bug** `Element.matches()` caches its result per element and selector, and invalidates it when the element itself, its parent or its siblings change. When a descendant combinator is resolved against an ancestor further up than the parent — `html[data-theme="dark"] span` for a `<span>` inside a `<div>` — a change on that ancestor does not invalidate the cached result: `span.matches('html[data-theme="dark"] span')` keeps returning whatever the first call returned, after `data-theme` is set or removed on `<html>`. `getComputedStyle()` matches style sheet rules through the same cache, so a rule keyed on an ancestor's attribute is not applied — or, once applied, never removed — until something else clears that element's cache (an attribute change on the element itself, a tree mutation touching it, or the internal `[PropertySymbol.clearCache]()`). Only ancestors beyond the direct parent are affected: with `body[data-theme="dark"] span`, a `<span>` that is a direct child of `<body>` updates correctly, a `<span>` inside a `<div>` does not. Class changes behave the same (`html.dark span`), and selectors written with child combinators throughout (`div[data-theme="dark"] > p > span`) are not affected. 14.12.3 behaves correctly; 15.0.0 (where the query selector cache was added, #1332) through 20.14.5 do not. This is different from #2366 (fixed in 20.14.1): there the computed style of the subtree was not recomputed; here it is recomputed, but from a stale `matches()` result. **To Reproduce** `npm i [email protected]`, save as `repro.mjs`, run `node repro.mjs`: ```js import { Window } from 'happy-dom'; const window = new Window(); const { document } = window; document.head.innerHTML = `<style> [data-mark] { --pick: light; color: rgb(1, 1, 1); } html[data-theme='dark'] [data-mark] { --pick: dark; color: rgb(2, 2, 2); } </style>`; document.body.innerHTML = `<span data-mark></span>`; const el = document.querySelector('[data-mark]'); const read = () => { const cs = window.getComputedStyle(el); return `--pick=${cs.getPropertyValue('--pick').trim()} color=${cs.color}`; }; console.log('1 initial :', read(), '(expected light)'); document.documentElement.setAttribute('data-theme', 'dark'); console.log('2 after html[data-theme=dark] :', read(), '(expected dark)'); el.setAttribute('data-nudge', ''); console.log("3 after el's own attribute change:", read(), '(expected dark)'); document.documentElement.removeAttribute('data-theme'); console.log('4 after removeAttribute :', read(), '(expected light)'); ``` Output on 20.14.5 (Node 22.14.0): ``` 1 initial : --pick=light color=rgb(1, 1, 1) (expected light) 2 after html[data-theme=dark] : --pick=light color=rgb(1, 1, 1) (expected dark) 3 after el's own attribute change: --pick=dark color=rgb(2, 2, 2) (expected dark) 4 after removeAttribute : --pick=dark color=rgb(2, 2, 2) (expected light) ``` Step 3 shows that the selector itself is evaluated correctly once the element's own cache is cleared; it is the invalidation that is missing. The same with `matches()` alone, no style sheet involved: ```js import { Window } from 'happy-dom'; const { document } = new Window(); document.body.innerHTML = '<div><span></span></div>'; const span = document.querySelector('span'); console.log(span.matches('html[data-theme="dark"] span')); // false document.documentElement.setAttribute('data-theme', 'dark'); console.log(span.matches('html[data-theme="dark"] span')); // expected true console.log(span.matches('html[data-theme="dark"] span')); // same selector, new string (not cached) console.log(document.querySelectorAll('html[data-theme="dark"] span').length); // expected 1 document.documentElement.removeAttribute('data-theme'); console.log(span.matches('html[data-theme="dark"] span')); // expected false ``` Output on 20.14.5: ``` false false true 1 true ``` The second line should be `true` — the third line (the same selector with two spaces, so it misses the cache) and `querySelectorAll` both find the match — and the last line should be `false`: the result cached as `true` under the two-space string is not invalidated either when the attribute is removed. **Expected behavior** After an attribute or class changes on any ancestor, `matches()` and `getComputedStyle()` of a descendant reflect the selectors that match now, as in a browser. The same steps in Chrome give `light`, `dark`, `dark`, `light` for the four reads and `false`, `true`, `true`, `false` for the four `matches()` calls. **Device and details:** - OS: Linux x64 (Ubuntu 24.04) - Node version: 22.14.0 - Package version: 20.14.5 (same on `master`; reproduced on every version from 15.0.0 on, 14.12.3 is correct) **Additional context** Where it goes wrong: `QuerySelector.matchSelector()` registers the parent in `affectsCache` in the child/descendant branch and then tries the next compound on the parent. When that compound does not match the parent, the fallback at the end of the method (`previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant`) recurses to the grandparent and further up, but without registering those ancestors, so `clearCache()` on `<html>` never reaches the descendant's cached item. Adding the same `this.affectsCache(<Element>parentElement, cachedItem)` call in that fallback fixes both scripts above; the whole `packages/happy-dom` test suite still passes with it. We are opening a PR with the change plus tests in `QuerySelector.test.ts` and `BrowserWindow.test.ts`. Workaround for test suites in the meantime: call `document[PropertySymbol.clearCache]()` right after the ancestor mutation and before any `getComputedStyle()` read, or `element[PropertySymbol.clearCache]()` on the element under test. Both rely on the internal `PropertySymbol` export. Found while building Zenium, an Electron-based browser: https://github.com/BenItBuhner/Zenium Disclosure, as CONTRIBUTING.md asks: this investigation, the repro and the proposed fix were prepared with AI assistance (Claude, via Cursor). The scripts, the version sweep and the test suite were run as described and the output is quoted verbatim.