Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@ function absolutize<Node, ElementNode extends Node>(
* @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<Node, ElementNode extends Node>(
token: InternalSelector[][],
options: InternalOptions<Node, ElementNode>,
compilationContext?: Node[] | Node,
isHasSubselector = false,
): CompiledQuery<ElementNode> {
for (const rules of token) {
sortRules(rules);
Expand Down Expand Up @@ -105,6 +107,8 @@ export function compileToken<Node, ElementNode extends Node>(
// Ignore
} else if (
isArrayContext &&
// Only the outer :has selector must exclude its anchor.
!isHasSubselector &&
second.type === SelectorType.Descendant
) {
rules[1] = FLEXIBLE_DESCENDANT_TOKEN;
Comment on lines +110 to 114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Explicit descendants match the anchor

On <div class="a"><i class="b"></i></div>, .a:has(:scope:is(:scope .a) > .b) now matches, although the anchor cannot be a descendant of itself. The nested :is() call leaves isHasSubselector false, so this branch rewrites its explicit :scope .a to _flexibleDescendant. That makes the query return an element that does not match.

Keep explicit descendant relationships strict inside :has() while allowing the implicit relationship needed by :is(.a).

Knowledge Base Used: Nested selector pseudo-selectors

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced on both the original base (ca67a21) and the current head (84a7029): .a:has(:scope:is(:scope .a) > .b) returns a match, and the corresponding :not(:scope .a) form returns none. The results agree across compile, is, and selectAll, with caching on and off.

The initial placeholder guard happened to suppress this case, but also broke the existing .a:has(:scope:is(.a) > .b) behavior. The follow-up restores that behavior while retaining the #1928 fix.

This explicit-scope case remains a pre-existing limitation related to #1931, which this PR leaves out of scope. Keeping it separate avoids changing query-scope semantics as part of the anchor-descendant fix.

This analysis was prepared with AI assistance.

Expand Down
2 changes: 1 addition & 1 deletion src/pseudo-selectors/subselects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const subselects: Record<string, Subselect> = {
: 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;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,5 @@ export type CompileToken<Node, ElementNode extends Node> = (
token: InternalSelector[][],
options: InternalOptions<Node, ElementNode>,
context?: Node[] | Node,
isHasSubselector?: boolean,
) => CompiledQuery<ElementNode>;
107 changes: 105 additions & 2 deletions test/pseudo-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,108 @@ describe(":empty", () => {
});

describe(":has", () => {
it("should exclude the anchor from implicit descendant matching", () => {
const document = parseDocument(
'<div class="a" id="anchor"><i class="b"></i></div>',
);
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 keep explicit scope inclusive in nested functional selectors", () => {
const document = parseDocument(
'<div class="a"><i class="b"></i></div>',
);
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(
'<div class="a" id="outer"><div class="a" id="inner"><i class="b"></i></div></div>',
);
for (const selector of [".a:has(.a .b)", ".a:has(.a > .b)"]) {
expect(
CSSselect.selectAll<AnyNode, Element>(selector, document).map(
(element) => element.attribs["id"],
),
).toEqual(["outer"]);
}
expect(
CSSselect.selectAll<AnyNode, Element>(".a:has(> .b)", document).map(
(element) => element.attribs["id"],
),
).toEqual(["inner"]);
});

it("should preserve array query roots and sibling matching", () => {
const document = parseDocument(
'<div class="a" id="outer"><div class="a" id="inner"><i class="b"></i><i class="c"></i></div></div>',
);
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(`
<div>
Expand All @@ -193,9 +295,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<AnyNode, Element>(compiled, dom),
).toHaveLength(2);
).toHaveLength(1);

(
((dom.childNodes[1] as Element).childNodes[1] as Element)
Expand All @@ -209,7 +312,7 @@ describe(":has", () => {
// But as we have cached the results in `compiled`, we should succeed here.
expect(
CSSselect.selectAll<AnyNode, Element>(compiled, dom),
).toHaveLength(2);
).toHaveLength(1);
});
});

Expand Down