Skip to content

Commit

Permalink
fix: Incorrect parsing of functional pseudo class css selector (#169)
Browse files Browse the repository at this point in the history
This fixes a parsing issue of CSS selectors that use a functional pseudo
class with multiple arguments.

For example,

```
.foo:has(button,div) {}
```

Would get parsed as 2 selectors: `.foo:has(button` and `div)` - this
results in an invalid stylesheet and looks like the replay is broken.

---------

Co-authored-by: Ryan Albrecht <ryan@ryanalbrecht.ca>
  • Loading branch information
billyvg and ryan953 authored Mar 25, 2024
1 parent f498b1f commit 810b39f
Show file tree
Hide file tree
Showing 2 changed files with 165 additions and 3 deletions.
87 changes: 84 additions & 3 deletions packages/rrweb-snapshot/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,96 @@ export function parse(css: string, options: ParserOptions = {}) {
}
/* @fix Remove all comments from selectors
* http://ostermiller.org/findcomment.html */
return trim(m[0])
const splitSelectors = trim(m[0])
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
return m.replace(/,/g, '\u200C');
})
.split(/\s*(?![^(]*\)),\s*/)
.map((s) => {
.split(/\s*(?![^(]*\)),\s*/);

if (splitSelectors.length <= 1) {
return splitSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
}

// For each selector, need to check if we properly split on `,`
// Example case where selector is:
// .bar:has(input:is(:disabled), button:is(:disabled))
let i = 0;
let j = 0;
const len = splitSelectors.length;
const finalSelectors = [];
while (i < len) {
// Look for selectors with opening parens - `(` and search rest of
// selectors for the first one with matching number of closing
// parens `)`
const openingParensCount = (splitSelectors[i].match(/\(/g) || []).length;
const closingParensCount = (splitSelectors[i].match(/\)/g) || []).length;
let unbalancedParens = openingParensCount - closingParensCount;

if (unbalancedParens >= 1) {
// At least one opening parens was found, prepare to look through
// rest of selectors
let foundClosingSelector = false;

// Loop starting with next item in array, until we find matching
// number of ending parens
j = i + 1;
while (j < len) {
// peek into next item to count the number of closing brackets
const nextOpeningParensCount = (splitSelectors[j].match(/\(/g) || [])
.length;
const nextClosingParensCount = (splitSelectors[j].match(/\)/g) || [])
.length;
const nextUnbalancedParens =
nextClosingParensCount - nextOpeningParensCount;

if (nextUnbalancedParens === unbalancedParens) {
// Matching # of closing parens was found, join all elements
// from i to j
finalSelectors.push(splitSelectors.slice(i, j + 1).join(','));

// we will want to skip the items that we have joined together
i = j + 1;

// Use to continue the outer loop
foundClosingSelector = true;

// break out of inner loop so we found matching closing parens
break;
}

// No matching closing parens found, keep moving through index, but
// update the # of unbalanced parents still outstanding
j++;
unbalancedParens -= nextUnbalancedParens;
}

if (foundClosingSelector) {
// Matching closing selector was found, move to next selector
continue;
}

// No matching closing selector was found, either invalid CSS,
// or unbalanced number of opening parens were used as CSS
// selectors. Assume that rest of the list of selectors are
// selectors and break to avoid iterating through the list of
// selectors again.
splitSelectors
.slice(i, len)
.forEach((selector) => selector && finalSelectors.push(selector));
break;
}

// No opening parens found, contiue looking through list
splitSelectors[i] && finalSelectors.push(splitSelectors[i]);
i++;
}

return finalSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
}

/**
Expand Down
81 changes: 81 additions & 0 deletions packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,87 @@ describe('css parser', () => {
expect(out3).toEqual('[data-aa\\:other] { color: red; }');
});

it.each([
['.foo,.bar {}', ['.foo', '.bar']],
['.bar:has(:disabled) {}', ['.bar:has(:disabled)']],
['.bar:has(input, button) {}', ['.bar:has(input, button)']],
[
'.bar:has(input:is(:disabled),button:has(:disabled)) {}',
['.bar:has(input:is(:disabled),button:has(:disabled))'],
],
[
'.bar:has(div, input:is(:disabled), button) {}',
['.bar:has(div,input:is(:disabled), button)'],
],
[
'.bar:has(div, input:is(:disabled),button:has(:disabled,.baz)) {}',
['.bar:has(div,input:is(:disabled),button:has(:disabled,.baz))'],
],
[
'.bar:has(input), .foo:has(input, button), .baz {}',
['.bar:has(input)', '.foo:has(input, button)', '.baz'],
],
[
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
[
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
],
],
['.bar((( {}', ['.bar(((']],
[
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button) {}',
[
'.bar:has(:has(:has(a),:has(:has(:has(b,:has(a), c), e))),input:is(:disabled), button)',
],
],
['.foo,.bar(((,.baz {}', ['.foo', '.bar(((', '.baz']],
[
'.foo,.bar:has(input:is(:disabled)){color: red;}',
['.foo', '.bar:has(input:is(:disabled))'],
],
[
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz)){color: red;}',
['.foo', '.bar:has(input:is(:disabled),button:has(:disabled,.baz))'],
],
[
'.foo,.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz)){color: red;}',
[
'.foo',
'.bar:has(input:is(:disabled),button:has(:disabled),div:has(:disabled,.baz))',
],
],
[
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
[
'.foo',
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
],
],
['.bar:has(:disabled), .foo {}', ['.bar:has(:disabled)', '.foo']],
[
'.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo {}',
['.bar:has(input:is(:disabled),.foo,button:is(:disabled))', '.foo'],
],
[
'.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo:has(input, button), .baz, {}',
[
'.bar:has(input:is(:disabled),.foo,button:is(:disabled))',
'.foo:has(input, button)',
'.baz',
],
],
])(
'can parse selector(s) with functional pseudo classes: %s',
(cssText, expected) => {
expect(
parse(
cssText,
// @ts-ignore
).stylesheet?.rules[0].selectors,
).toEqual(expected);
},
);

it('parses imports with quotes correctly', () => {
const out1 = escapeImportStatement({
cssText: `@import url("/foo.css;900;800"");`,
Expand Down

0 comments on commit 810b39f

Please sign in to comment.