From 295f3b4cf781356d4c4b358aeb991e344ed316c0 Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 14:57:04 +0530 Subject: [PATCH 1/6] fix: exclude the :has anchor from implicit descendants --- src/compile.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/compile.ts b/src/compile.ts index a804aa63..8e688f71 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -105,6 +105,8 @@ export function compileToken( // Ignore } else if ( isArrayContext && + // :has uses an array only to replace its anchor per element. + finalContext?.[0] !== PLACEHOLDER_ELEMENT && second.type === SelectorType.Descendant ) { rules[1] = FLEXIBLE_DESCENDANT_TOKEN; From dc61b2246d5a11406787fd0d466d3fe66e07184b Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 14:58:00 +0530 Subject: [PATCH 2/6] fix: exclude the :has anchor from implicit descendants --- test/pseudo-classes.ts | 72 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/test/pseudo-classes.ts b/test/pseudo-classes.ts index 1feeb8ce..cc827e3f 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -181,6 +181,73 @@ describe(":empty", () => { }); describe(":has", () => { + it("should exclude the anchor from implicit descendant matching", () => { + const document = parseDocument( + '
', + ); + const anchor = document.children[0] as Element; + + for (const shouldCacheResults of [true, false]) { + for (const selector of [".a:has(.a .b)", ".a:has(.a > .b)"]) { + expect( + CSSselect.compile(selector, { + cacheResults: shouldCacheResults, + })(anchor), + ).toBe(false); + expect( + CSSselect.is(anchor, selector, { + cacheResults: shouldCacheResults, + }), + ).toBe(false); + expect( + CSSselect.selectAll(selector, document, { + cacheResults: shouldCacheResults, + }), + ).toHaveLength(0); + } + } + }); + + it("should still match descendants and leading child combinators", () => { + const document = parseDocument( + '
', + ); + for (const selector of [".a:has(.a .b)", ".a:has(.a > .b)"]) { + expect( + CSSselect.selectAll(selector, document).map( + (element) => element.attribs["id"], + ), + ).toEqual(["outer"]); + } + expect( + CSSselect.selectAll(".a:has(> .b)", document).map( + (element) => element.attribs["id"], + ), + ).toEqual(["inner"]); + }); + + it("should preserve array query roots and sibling matching", () => { + const document = parseDocument( + '
', + ); + const outer = document.children[0] as Element; + const inner = outer.children[0] as Element; + + expect( + CSSselect.selectAll(".a .b", [outer]).map( + (element) => element.attribs["class"], + ), + ).toEqual(["b"]); + expect(CSSselect.is(inner, ".a:has(> .b + .c)")).toBe(true); + expect(CSSselect.is(inner.children[0], ".b:has(+ .c)")).toBe(true); + expect(CSSselect.is(inner.children[0], ".b:has(~ .c)")).toBe(true); + expect( + CSSselect.selectAll(".a .b", [inner]).map( + (element) => element.attribs["class"], + ), + ).toEqual(["b"]); + }); + it("should cache :has if applicable", () => { const dom = parseDocument(`
@@ -193,9 +260,10 @@ describe(":has", () => { `); const compiled = CSSselect.compile(":has(.a .b ~ .c)"); + // Only the outer div has a descendant .a containing .b ~ .c. expect( CSSselect.selectAll(compiled, dom), - ).toHaveLength(2); + ).toHaveLength(1); ( ((dom.childNodes[1] as Element).childNodes[1] as Element) @@ -209,7 +277,7 @@ describe(":has", () => { // But as we have cached the results in `compiled`, we should succeed here. expect( CSSselect.selectAll(compiled, dom), - ).toHaveLength(2); + ).toHaveLength(1); }); }); From fccb9a26b1c78e13f7e8db2868cdeb2bfa977c70 Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 20:04:43 +0530 Subject: [PATCH 3/6] fix: preserve nested anchor matching inside :has --- src/compile.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/compile.ts b/src/compile.ts index 8e688f71..e10b6682 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -65,11 +65,13 @@ function absolutize( * @param token Selector token(s) to compile. * @param options Options that control this operation. * @param compilationContext Compilation context for relative selector handling. + * @param isHasSubselector Whether this is the outer selector of a :has pseudo. */ export function compileToken( token: InternalSelector[][], options: InternalOptions, compilationContext?: Node[] | Node, + isHasSubselector = false, ): CompiledQuery { for (const rules of token) { sortRules(rules); @@ -105,8 +107,8 @@ export function compileToken( // Ignore } else if ( isArrayContext && - // :has uses an array only to replace its anchor per element. - finalContext?.[0] !== PLACEHOLDER_ELEMENT && + // Only the outer :has selector must exclude its anchor. + !isHasSubselector && second.type === SelectorType.Descendant ) { rules[1] = FLEXIBLE_DESCENDANT_TOKEN; From 9393c500cf07da281c808e61c0b06a41058f6280 Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 20:05:04 +0530 Subject: [PATCH 4/6] fix: preserve nested anchor matching inside :has --- src/pseudo-selectors/subselects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pseudo-selectors/subselects.ts b/src/pseudo-selectors/subselects.ts index e93f4804..b0fd0ea8 100644 --- a/src/pseudo-selectors/subselects.ts +++ b/src/pseudo-selectors/subselects.ts @@ -87,7 +87,7 @@ export const subselects: Record = { : undefined; const shouldSkipCache = isContextDependent(subselect); - const compiled = compileToken(subselect, copiedOptions, context); + const compiled = compileToken(subselect, copiedOptions, context, true); if (compiled === boolbase.falseFunc) { return boolbase.falseFunc; From 026e772958db48f500c476968d18878937bad8ae Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 20:05:30 +0530 Subject: [PATCH 5/6] Add isHasSubselector parameter to CompileToken type --- src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types.ts b/src/types.ts index 0a364f3b..48300c8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -204,4 +204,5 @@ export type CompileToken = ( token: InternalSelector[][], options: InternalOptions, context?: Node[] | Node, + isHasSubselector?: boolean, ) => CompiledQuery; From 84a7029284e395c99fdf38e8338a5321126704d3 Mon Sep 17 00:00:00 2001 From: Manav Date: Fri, 6 Nov 2026 20:05:53 +0530 Subject: [PATCH 6/6] Add test for inclusive scope in nested selectors --- test/pseudo-classes.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/pseudo-classes.ts b/test/pseudo-classes.ts index cc827e3f..c884fd85 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -208,6 +208,41 @@ describe(":has", () => { } }); + it("should keep explicit scope inclusive in nested functional selectors", () => { + const document = parseDocument( + '
', + ); + const anchor = document.children[0] as Element; + + for (const shouldCacheResults of [true, false]) { + for (const [selector, expected] of [ + [".a:has(:scope.a > .b)", true], + [".a:has(:scope:is(.a) > .b)", true], + [".a:has(:scope:where(.a) > .b)", true], + [".a:has(:scope:matches(.a) > .b)", true], + [".a:has(:scope:not(.a) > .b)", false], + [".a:has(:scope:not(.missing) > .b)", true], + [".a:has(:scope:is(:not(.missing)) > .b)", true], + ] as const) { + expect( + CSSselect.compile(selector, { + cacheResults: shouldCacheResults, + })(anchor), + ).toBe(expected); + expect( + CSSselect.is(anchor, selector, { + cacheResults: shouldCacheResults, + }), + ).toBe(expected); + expect( + CSSselect.selectAll(selector, document, { + cacheResults: shouldCacheResults, + }), + ).toHaveLength(expected ? 1 : 0); + } + } + }); + it("should still match descendants and leading child combinators", () => { const document = parseDocument( '
',