Skip to content

Commit fc6db8d

Browse files
committed
Merge branch 'feature/matches-perf' into next
2 parents ae0e3d1 + 9175e43 commit fc6db8d

File tree

8 files changed

+205
-23
lines changed

8 files changed

+205
-23
lines changed

etc/browser.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ export class HtmlElement extends DOMNode {
510510
is(tagName: string): boolean;
511511
get lastElementChild(): HtmlElement | null;
512512
loadMeta(meta: MetaElement): void;
513-
matches(selector: string): boolean;
513+
matches(selectorList: string): boolean;
514514
// (undocumented)
515515
get meta(): MetaElement | null;
516516
// (undocumented)

etc/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ export class HtmlElement extends DOMNode {
601601
is(tagName: string): boolean;
602602
get lastElementChild(): HtmlElement | null;
603603
loadMeta(meta: MetaElement): void;
604-
matches(selector: string): boolean;
604+
matches(selectorList: string): boolean;
605605
// (undocumented)
606606
get meta(): MetaElement | null;
607607
// (undocumented)

src/dom/htmlelement.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -375,25 +375,11 @@ export class HtmlElement extends DOMNode {
375375
*
376376
* Implementation of DOM specification of Element.matches(selectors).
377377
*/
378-
public matches(selector: string): boolean {
379-
/* find root element */
380-
/* eslint-disable-next-line @typescript-eslint/no-this-alias -- false positive */
381-
let root: HtmlElement = this;
382-
while (root.parent) {
383-
root = root.parent;
384-
}
385-
386-
/* a bit slow implementation as it finds all candidates for the selector and
387-
* then tests if any of them are the current element. A better
388-
* implementation would be to walk the selector right-to-left and test
389-
* ancestors. */
390-
for (const match of root.querySelectorAll(selector)) {
391-
if (match.unique === this.unique) {
392-
return true;
393-
}
394-
}
395-
396-
return false;
378+
public matches(selectorList: string): boolean {
379+
return selectorList.split(",").some((it) => {
380+
const selector = new Selector(it.trim());
381+
return selector.matchElement(this);
382+
});
397383
}
398384

399385
public get meta(): MetaElement | null {

src/dom/pseudoclass/scope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import { type HtmlElement } from "../htmlelement";
22
import { type SelectorContext } from "../selector";
33

44
export function scope(this: SelectorContext, node: HtmlElement): boolean {
5-
return node.isSameNode(this.scope);
5+
return Boolean(this.scope && node.isSameNode(this.scope));
66
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Config } from "../../config";
2+
import { Parser } from "../../parser";
3+
import { Selector } from "./selector";
4+
5+
let parser: Parser;
6+
7+
beforeAll(async () => {
8+
const resolvedConfig = await Config.empty().resolve();
9+
parser = new Parser(resolvedConfig);
10+
});
11+
12+
it("should match simple selector", () => {
13+
expect.assertions(8);
14+
const markup = /* HTML */ ` <p class="foo">lorem <em>ipsum</em></p> `;
15+
const document = parser.parseHtml(markup);
16+
const p = document.querySelector("p")!;
17+
const em = document.querySelector("em")!;
18+
expect(new Selector("p").matchElement(p)).toBeTruthy();
19+
expect(new Selector("p").matchElement(em)).toBeFalsy();
20+
expect(new Selector("em").matchElement(p)).toBeFalsy();
21+
expect(new Selector("em").matchElement(em)).toBeTruthy();
22+
expect(new Selector("div").matchElement(p)).toBeFalsy();
23+
expect(new Selector("div").matchElement(em)).toBeFalsy();
24+
expect(new Selector(".foo").matchElement(p)).toBeTruthy();
25+
expect(new Selector(".foo").matchElement(em)).toBeFalsy();
26+
});
27+
28+
it("should match simple selectors with descendant combinator", () => {
29+
expect.assertions(5);
30+
const markup = /* HTML */ `
31+
<div>
32+
<h1>lorem <em>ipsum</em></h1>
33+
<p>lorem <em>ipsum</em></p>
34+
<h2>lorem <em>ipsum</em></h2>
35+
</div>
36+
`;
37+
const document = parser.parseHtml(markup);
38+
const em = document.querySelector("p > em")!;
39+
expect(new Selector("p em").matchElement(em)).toBeTruthy();
40+
expect(new Selector("div em").matchElement(em)).toBeTruthy();
41+
expect(new Selector("div p em").matchElement(em)).toBeTruthy();
42+
expect(new Selector("h1 em").matchElement(em)).toBeFalsy();
43+
expect(new Selector("h2 em").matchElement(em)).toBeFalsy();
44+
});
45+
46+
it("should match simple selectors with child combinator", () => {
47+
expect.assertions(5);
48+
const markup = /* HTML */ `
49+
<div>
50+
<h1>lorem <em>ipsum</em></h1>
51+
<p>lorem <em>ipsum</em></p>
52+
<h2>lorem <em>ipsum</em></h2>
53+
</div>
54+
`;
55+
const document = parser.parseHtml(markup);
56+
const em = document.querySelector("p > em")!;
57+
expect(new Selector("p > em").matchElement(em)).toBeTruthy();
58+
expect(new Selector("div > em").matchElement(em)).toBeFalsy();
59+
expect(new Selector("div > p > em").matchElement(em)).toBeTruthy();
60+
expect(new Selector("h1 > em").matchElement(em)).toBeFalsy();
61+
expect(new Selector("h2 > em").matchElement(em)).toBeFalsy();
62+
});
63+
64+
it("should match simple selectors with adjacent sibling combinator", () => {
65+
expect.assertions(5);
66+
const markup = /* HTML */ `
67+
<div>
68+
<h1>lorem <em>ipsum</em></h1>
69+
<p>lorem <em>ipsum</em></p>
70+
<h2>lorem <em>ipsum</em></h2>
71+
</div>
72+
`;
73+
const document = parser.parseHtml(markup);
74+
const p = document.querySelector("p")!;
75+
const h2 = document.querySelector("h2")!;
76+
expect(new Selector("h1 + p").matchElement(p)).toBeTruthy();
77+
expect(new Selector("p + h2").matchElement(h2)).toBeTruthy();
78+
expect(new Selector("h2 + p").matchElement(p)).toBeFalsy();
79+
expect(new Selector("h1 + h2").matchElement(h2)).toBeFalsy();
80+
expect(new Selector("div + p").matchElement(p)).toBeFalsy();
81+
});
82+
83+
it("should match simple selectors with general sibling combinator", () => {
84+
expect.assertions(5);
85+
const markup = /* HTML */ `
86+
<div>
87+
<h1>lorem <em>ipsum</em></h1>
88+
<p>lorem <em>ipsum</em></p>
89+
<h2>lorem <em>ipsum</em></h2>
90+
</div>
91+
`;
92+
const document = parser.parseHtml(markup);
93+
const p = document.querySelector("p")!;
94+
const h2 = document.querySelector("h2")!;
95+
expect(new Selector("h1 ~ p").matchElement(p)).toBeTruthy();
96+
expect(new Selector("p ~ h2").matchElement(h2)).toBeTruthy();
97+
expect(new Selector("h2 ~ p").matchElement(p)).toBeFalsy();
98+
expect(new Selector("h1 ~ h2").matchElement(h2)).toBeTruthy();
99+
expect(new Selector("div ~ p").matchElement(p)).toBeFalsy();
100+
});

src/dom/selector/match-element.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { type HtmlElement } from "../htmlelement";
2+
import { Combinator } from "./combinator";
3+
import { type Compound } from "./compound";
4+
import { type SelectorContext } from "./selector-context";
5+
6+
function* ancestors(element: HtmlElement): Generator<HtmlElement> {
7+
let current = element.parent;
8+
while (current && !current.isRootElement()) {
9+
yield current;
10+
current = current.parent;
11+
}
12+
}
13+
14+
function* parent(element: HtmlElement): Generator<HtmlElement> {
15+
const parent = element.parent;
16+
if (parent && !parent.isRootElement()) {
17+
yield parent;
18+
}
19+
}
20+
21+
function* adjacentSibling(element: HtmlElement): Generator<HtmlElement> {
22+
const sibling = element.previousSibling;
23+
if (sibling) {
24+
yield sibling;
25+
}
26+
}
27+
28+
function* generalSibling(element: HtmlElement): Generator<HtmlElement> {
29+
const siblings = element.siblings;
30+
const index = siblings.findIndex((it) => it.isSameNode(element));
31+
for (let i = 0; i < index; i++) {
32+
yield siblings[i];
33+
}
34+
}
35+
36+
function* scope(element: HtmlElement): Generator<HtmlElement> {
37+
yield element;
38+
}
39+
40+
function candidatesFromCombinator(
41+
element: HtmlElement,
42+
combinator: Combinator,
43+
): Generator<HtmlElement> {
44+
switch (combinator) {
45+
case Combinator.DESCENDANT:
46+
return ancestors(element);
47+
case Combinator.CHILD:
48+
return parent(element);
49+
case Combinator.ADJACENT_SIBLING:
50+
return adjacentSibling(element);
51+
case Combinator.GENERAL_SIBLING:
52+
return generalSibling(element);
53+
case Combinator.SCOPE:
54+
return scope(element);
55+
}
56+
}
57+
58+
/**
59+
* @internal
60+
*/
61+
export function matchElement(
62+
element: HtmlElement,
63+
compounds: Compound[],
64+
context: SelectorContext,
65+
): boolean {
66+
if (compounds.length === 0) {
67+
return true;
68+
}
69+
const last = compounds[compounds.length - 1];
70+
if (!last.match(element, context)) {
71+
return false;
72+
}
73+
74+
const remainder = compounds.slice(0, -1);
75+
if (remainder.length === 0) {
76+
return true;
77+
}
78+
79+
const candidates = candidatesFromCombinator(element, last.combinator);
80+
for (const candidate of candidates) {
81+
if (matchElement(candidate, remainder, context)) {
82+
return true;
83+
}
84+
}
85+
86+
return false;
87+
}

src/dom/selector/selector-context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ import { type HtmlElement } from "../htmlelement";
55
*/
66
export interface SelectorContext {
77
/** Scope element */
8-
scope: HtmlElement;
8+
scope: HtmlElement | null;
99
}

src/dom/selector/selector.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type DynamicValue } from "../dynamic-value";
22
import { type HtmlElement } from "../htmlelement";
33
import { Combinator } from "./combinator";
44
import { Compound } from "./compound";
5+
import { matchElement } from "./match-element";
56
import { type SelectorContext } from "./selector-context";
67
import { splitSelectorElements } from "./split-selector-elements";
78

@@ -70,6 +71,14 @@ export class Selector {
7071
yield* this.matchInternal(root, 0, context);
7172
}
7273

74+
/**
75+
* Returns `true` if the element matches this selector.
76+
*/
77+
public matchElement(element: HtmlElement): boolean {
78+
const context: SelectorContext = { scope: null };
79+
return matchElement(element, this.pattern, context);
80+
}
81+
7382
private *matchInternal(
7483
root: HtmlElement,
7584
level: number,

0 commit comments

Comments
 (0)