Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/bright-selectors-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@remote-dom/polyfill': patch
---

Correct chained combinators, whitespace, exact attribute equality, and scoped relative `:has()` selectors, including leading combinators, nested functional pseudo-classes, and ASCII-case-insensitive pseudo-class names.
197 changes: 130 additions & 67 deletions packages/polyfill/source/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const MATCHER_CLASS = 3;
export const MATCHER_ATTRIBUTE = 4;
export const MATCHER_PSEUDO = 5;
export const MATCHER_FUNCTION = 6;
export const MATCHER_SCOPE = 7;

export type MatcherType =
| typeof MATCHER_UNKNOWN
Expand All @@ -40,7 +41,8 @@ export type MatcherType =
| typeof MATCHER_CLASS
| typeof MATCHER_ATTRIBUTE
| typeof MATCHER_PSEUDO
| typeof MATCHER_FUNCTION;
| typeof MATCHER_FUNCTION
| typeof MATCHER_SCOPE;

export interface Part {
combinator: Combinator;
Expand All @@ -55,6 +57,37 @@ export interface Matcher {

const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/;

function readFunctionArgument(
selector: string,
start: number,
): [string, number] {
let depth = 1;
let quote: string | null = null;

for (let index = start; index < selector.length; index++) {
const character = selector[index]!;

if (quote) {
if (character === '\\') {
index++;
} else if (character === quote) {
quote = null;
}
continue;
}

if (character === '"' || character === "'") {
quote = character;
} else if (character === '(') {
depth++;
} else if (character === ')' && --depth === 0) {
return [selector.slice(start, index), index + 1];
}
}

return [selector.slice(start), selector.length];
}

export function querySelector(
within: ParentNode,
selector: string | Matcher[],
Expand Down Expand Up @@ -94,20 +127,24 @@ export function querySelectorAll(
return results;
}

export function parseSelector(selector: string) {
export function parseSelector(selector: string, insideHas = false) {
let part: Part = {combinator: COMBINATOR_INNER, matchers: []};
const parts = [part];
const tokenizer =
/\s*?([>\s+~]?)\s*?(?:(?:\[\s*([^\]=]+)(?:=(['"])(.*?)\3)?\s*\])|([#.]?)([^\s#.[>:+~]+)|:(\w+)(?:\((.*?)\))?)/gi;
/[\t\n\f\r ]*?([>\t\n\f\r +~]?)[\t\n\f\r ]*?(?:(?:\[[\t\n\f\r ]*([^\]=\t\n\f\r ]+)[\t\n\f\r ]*(?:=[\t\n\f\r ]*(?:(['"])(.*?)\3|([^\]\t\n\f\r ]+)))?[\t\n\f\r ]*\])|([#.]?)([^\t\n\f\r #.[>:+~()]+)|:(\w+)(\()?)/gi;
const normalizedSelector = selector.replace(
/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g,
'',
);
let token;
while ((token = tokenizer.exec(selector))) {
while ((token = tokenizer.exec(normalizedSelector))) {
// [1]: ancestor/parent/sibling/adjacent
// [2]: attribute name
// [4]: attribute value
// [5]: id/class sigil
// [6]: id/class name
// [7]: :pseudo/:function() name
// [8]: :function(argument) value
// [4]/[5]: quoted/unquoted attribute value
// [6]: id/class sigil
// [7]: id/class name
// [8]: :pseudo/:function() name
// [9]: :function opening parenthesis
if (token[1]) {
// Update the combinator on the (now parent) Part:
if (token[1] === '>') part.combinator = COMBINATOR_CHILD;
Expand All @@ -122,46 +159,98 @@ export function parseSelector(selector: string) {
let type: MatcherType = MATCHER_UNKNOWN;
if (token[2]) {
type = MATCHER_ATTRIBUTE;
} else if (token[5]) {
type = token[5] === '#' ? MATCHER_ID : MATCHER_CLASS;
} else if (token[7]) {
type = token[8] == null ? MATCHER_PSEUDO : MATCHER_FUNCTION;
} else if (token[6]) {
if (token[6] === '*') {
type = token[6] === '#' ? MATCHER_ID : MATCHER_CLASS;
} else if (token[8]) {
type = token[9] == null ? MATCHER_PSEUDO : MATCHER_FUNCTION;
} else if (token[7]) {
if (token[7] === '*') {
type = MATCHER_UNKNOWN; // Universal selector matches all
} else if (ELEMENT_SELECTOR_TEST.test(token[6])) {
} else if (ELEMENT_SELECTOR_TEST.test(token[7])) {
type = MATCHER_ELEMENT;
}
}
let value = token[4] ?? token[5] ?? token[7];
if (token[9]) {
[value, tokenizer.lastIndex] = readFunctionArgument(
normalizedSelector,
tokenizer.lastIndex,
);
}
const name = token[8] ? asciiLowercase(token[8]) : (token[2] || token[7])!;
if (type === MATCHER_FUNCTION && (name === 'has' || name === 'not')) {
if (name === 'has' && insideHas) {
throw Error(':has() cannot be nested inside :has()');
}
parseSelector(value!, insideHas || name === 'has');
}
part.matchers.push({
type,
name: (token[2] || token[6] || token[7])!,
value: token[4] ?? token[6] ?? token[8],
name,
value,
});
}
return parts;
}

function matchesSelector(element: Element, selector: string) {
const parsed = parseSelector(selector);
let part: Part | undefined;
while ((part = parsed.pop())) {
if (!matchesSelectorPart(element, part)) return false;
return parsed[0]?.matchers.length
? matchesSelectorRecursive(element, parsed)
: false;
}

function matchesRelativeSelector(scope: Element, selector: string) {
const parts = parseSelector(selector);
const first = parts[0]!;
if (parts.length === 1 && first.matchers.length === 0) return false;

let leadingCombinator = COMBINATOR_DESCENDANT;
const scopeMatcher: Matcher = {type: MATCHER_SCOPE, name: ':scope'};
if (first.matchers.length === 0) {
leadingCombinator = first.combinator;
first.matchers.push(scopeMatcher);
} else {
parts.unshift({
combinator: COMBINATOR_DESCENDANT,
matchers: [scopeMatcher],
});
}
return true;

if (parts.some(({matchers}) => matchers.length === 0)) return false;

const root =
leadingCombinator === COMBINATOR_ADJACENT ||
leadingCombinator === COMBINATOR_SIBLING
? scope[NEXT]
: scope[CHILD];
if (!root) return false;

let matched = false;
walkNodesForSelector(
root,
parts,
() => {
matched = true;
return false;
},
scope,
);
return matched;
}

function walkNodesForSelector(
node: Node,
parts: Part[],
callback: (node: Element) => boolean | void,
scope?: Element,
) {
const pendingSiblings: Node[] = [];
let current: Node | null = node;

while (current) {
if (isElementNode(current)) {
if (matchesSelectorRecursive(current, parts)) {
if (matchesSelectorRecursive(current, parts, scope)) {
if (callback(current) === false) return false;
}

Expand All @@ -180,12 +269,16 @@ function walkNodesForSelector(
return true;
}

function matchesSelectorRecursive(element: Element, parts: Part[]): boolean {
function matchesSelectorRecursive(
element: Element,
parts: Part[],
scope?: Element,
): boolean {
const {combinator, matchers} = parts[parts.length - 1]!;
if (combinator === COMBINATOR_INNER) {
if (!matchesSelectorMatcher(element, matchers)) return false;
if (!matchesSelectorMatcher(element, matchers, scope)) return false;
const pp = parts.slice(0, -1);
return pp.length === 0 || matchesSelectorRecursive(element, pp);
return pp.length === 0 || matchesSelectorRecursive(element, pp, scope);
}
const link =
combinator === COMBINATOR_CHILD || combinator === COMBINATOR_DESCENDANT
Expand All @@ -200,10 +293,10 @@ function matchesSelectorRecursive(element: Element, parts: Part[]): boolean {
) {
// For descendant/sibling combinators, search through all ancestors/siblings
while (ref) {
if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers)) {
if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers, scope)) {
const pp = parts.slice(0, -1);
if (pp.length === 0) return true;
if (matchesSelectorRecursive(element, pp)) return true;
if (matchesSelectorRecursive(ref, pp, scope)) return true;
}
ref = ref[link];
}
Expand All @@ -219,49 +312,14 @@ function matchesSelectorRecursive(element: Element, parts: Part[]): boolean {
if (!ref) return false;
}

if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers)) {
if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers, scope)) {
return false;
}
const pp = parts.slice(0, -1);
return pp.length === 0 || matchesSelectorRecursive(element, pp);
return pp.length === 0 || matchesSelectorRecursive(ref, pp, scope);
}
}

function matchesSelectorPart(element: Element, {combinator, matchers}: Part) {
if (combinator === COMBINATOR_INNER) {
return matchesSelectorMatcher(element, matchers);
}
const link =
combinator === COMBINATOR_CHILD || combinator === COMBINATOR_DESCENDANT
? PARENT
: PREV;
let ref = element[link];
if (!ref) return false;

// For sibling combinators, skip non-element siblings
if (combinator === COMBINATOR_ADJACENT && !isElementNode(ref)) {
while (ref && !isElementNode(ref)) {
ref = ref[link];
}
if (!ref) return false;
}

if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers)) {
return false;
}

if (
combinator === COMBINATOR_DESCENDANT ||
combinator === COMBINATOR_SIBLING
) {
while ((ref = ref[link])) {
if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers))
return true;
}
}
return true;
}

function getSelectorAttribute(element: Element, name: string) {
return element.getAttributeNS(
null,
Expand All @@ -272,11 +330,14 @@ function getSelectorAttribute(element: Element, name: string) {
function matchesSelectorMatcher(
element: Element | null,
matcher: Matcher | Matcher[],
scope?: Element,
) {
if (!element) return false;
if (Array.isArray(matcher)) {
for (const single of matcher) {
if (matchesSelectorMatcher(element, single) === false) return false;
if (matchesSelectorMatcher(element, single, scope) === false) {
return false;
}
}
return true;
}
Expand All @@ -293,10 +354,12 @@ function matchesSelectorMatcher(
case MATCHER_CLASS:
const classAttr = getSelectorAttribute(element, 'class');
if (!classAttr) return false;
return classAttr.split(/\s+/).includes(name);
return classAttr.split(/[\t\n\f\r ]+/).includes(name);
case MATCHER_ATTRIBUTE:
const attribute = getSelectorAttribute(element, name);
return value == null ? attribute != null : attribute === value;
case MATCHER_SCOPE:
return element === scope;
case MATCHER_PSEUDO:
switch (name) {
default:
Expand All @@ -305,7 +368,7 @@ function matchesSelectorMatcher(
case MATCHER_FUNCTION:
switch (name) {
case 'has':
return matchesSelector(element, value || '');
return matchesRelativeSelector(element, value || '');
case 'not':
return !matchesSelector(element, value || '');
default:
Expand Down
Loading
Loading