From 2879664f85a8cac89592df5dd8f40c43eb615b1a Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 8 Nov 2026 11:42:20 +0800 Subject: [PATCH] fix: avoid reusing descendant results across :has anchors --- src/compile.ts | 8 +++- test/descendant-cache.ts | 96 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/descendant-cache.ts diff --git a/src/compile.ts b/src/compile.ts index a804aa63..2558bef2 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -85,6 +85,12 @@ export function compileToken( const finalContext = context && (Array.isArray(context) ? context : [context]); + /* + * :has replaces its placeholder for each element. Descendant results may + * depend on that element, so they cannot be cached by ancestor alone. + */ + const hasMutableContext = finalContext?.[0] === PLACEHOLDER_ELEMENT; + // Check if the selector is relative if (options.relativeSelector !== false) { absolutize(token, options, finalContext); @@ -131,7 +137,7 @@ export function compileToken( const quality = getQuality(rule); - if (quality === 0) { + if (quality === 0 && !hasMutableContext) { hasExpensiveSubselector = true; } diff --git a/test/descendant-cache.ts b/test/descendant-cache.ts new file mode 100644 index 00000000..e5755add --- /dev/null +++ b/test/descendant-cache.ts @@ -0,0 +1,96 @@ +import type { AnyNode, Element } from "domhandler"; +import { DomUtils, parseDocument } from "htmlparser2"; +import { describe, expect, it, vi } from "vitest"; +import * as CSSselect from "../src/index.js"; + +const html = + '
' + + 'needle
'; + +describe("descendant caching inside :has", () => { + const selectors = [ + ":has(:is(.c .b) :disabled)", + ":has(:where(.c .b) :disabled)", + ":has(:matches(.c .b) :disabled)", + ":has(:not(:not(.c .b)) :disabled)", + ':has(:is(.c .b):contains("needle") :disabled)', + ':has(:is(.c .b):icontains("NEEDLE") :disabled)', + ':has(:not(:not(.c .b)):contains("needle") :disabled)', + ":has(> :is(.c .b) :disabled)", + ":has(:is(:is(.c .b) :disabled))", + ]; + + it.each(selectors)( + "does not reuse another anchor's answers for %s", + (selector) => { + const document = parseDocument(html); + const elements = DomUtils.findAll( + (element) => !!element.attribs["id"], + document.children, + ); + // eslint-disable-next-line unicorn/no-array-reverse -- The project targets ES2022. + for (const ordered of [elements, [...elements].reverse()]) { + const expected = ordered.filter( + CSSselect.compile(selector, { cacheResults: false }), + ); + const compiled = CSSselect.compile(selector); + expect(ordered.filter(compiled)).toStrictEqual(expected); + expect(ordered.filter(compiled)).toStrictEqual(expected); + } + expect(CSSselect.selectAll(selector, document)).toStrictEqual( + CSSselect.selectAll(selector, document, { + cacheResults: false, + }), + ); + }, + ); + + it("matches the reported tree independently of evaluation order", () => { + const document = parseDocument(html); + const selector = ":has(:is(.c .b) :disabled)"; + expect( + CSSselect.selectAll(selector, document).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["o", "m"]); + const elements = CSSselect.selectAll( + "[id]", + document, + ); + elements.reverse(); + const compiled = CSSselect.compile(selector); + expect( + elements.filter(compiled).map((element) => element.attribs["id"]), + ).toStrictEqual(["m", "o"]); + }); + + it("retains descendant caching with a stable query context", () => { + const document = parseDocument(html); + const elements = CSSselect.selectAll( + "[id]", + document, + ); + const input = CSSselect.selectOne("input", document); + if (input === null) throw new Error("Missing test input"); + + for (const context of [undefined, elements[0], [elements[0]]]) { + for (const shouldCache of [true, false]) { + const marker = vi.fn(() => true); + const compiled = CSSselect.compile( + ":is(.c .b):marker :disabled", + { pseudos: { marker }, cacheResults: shouldCache }, + context, + ); + expect(compiled(input)).toBe(true); + const calls = marker.mock.calls.length; + expect(calls).toBeGreaterThan(0); + expect(compiled(input)).toBe(true); + if (shouldCache) { + expect(marker).toHaveBeenCalledTimes(calls); + } else { + expect(marker.mock.calls.length).toBeGreaterThan(calls); + } + } + } + }); +});