Skip to content

Commit 810b39f

Browse files
billyvgryan953
andauthored
fix: Incorrect parsing of functional pseudo class css selector (#169)
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 <[email protected]>
1 parent f498b1f commit 810b39f

File tree

2 files changed

+165
-3
lines changed

2 files changed

+165
-3
lines changed

packages/rrweb-snapshot/src/css.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -437,15 +437,96 @@ export function parse(css: string, options: ParserOptions = {}) {
437437
}
438438
/* @fix Remove all comments from selectors
439439
* http://ostermiller.org/findcomment.html */
440-
return trim(m[0])
440+
const splitSelectors = trim(m[0])
441441
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
442442
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
443443
return m.replace(/,/g, '\u200C');
444444
})
445-
.split(/\s*(?![^(]*\)),\s*/)
446-
.map((s) => {
445+
.split(/\s*(?![^(]*\)),\s*/);
446+
447+
if (splitSelectors.length <= 1) {
448+
return splitSelectors.map((s) => {
447449
return s.replace(/\u200C/g, ',');
448450
});
451+
}
452+
453+
// For each selector, need to check if we properly split on `,`
454+
// Example case where selector is:
455+
// .bar:has(input:is(:disabled), button:is(:disabled))
456+
let i = 0;
457+
let j = 0;
458+
const len = splitSelectors.length;
459+
const finalSelectors = [];
460+
while (i < len) {
461+
// Look for selectors with opening parens - `(` and search rest of
462+
// selectors for the first one with matching number of closing
463+
// parens `)`
464+
const openingParensCount = (splitSelectors[i].match(/\(/g) || []).length;
465+
const closingParensCount = (splitSelectors[i].match(/\)/g) || []).length;
466+
let unbalancedParens = openingParensCount - closingParensCount;
467+
468+
if (unbalancedParens >= 1) {
469+
// At least one opening parens was found, prepare to look through
470+
// rest of selectors
471+
let foundClosingSelector = false;
472+
473+
// Loop starting with next item in array, until we find matching
474+
// number of ending parens
475+
j = i + 1;
476+
while (j < len) {
477+
// peek into next item to count the number of closing brackets
478+
const nextOpeningParensCount = (splitSelectors[j].match(/\(/g) || [])
479+
.length;
480+
const nextClosingParensCount = (splitSelectors[j].match(/\)/g) || [])
481+
.length;
482+
const nextUnbalancedParens =
483+
nextClosingParensCount - nextOpeningParensCount;
484+
485+
if (nextUnbalancedParens === unbalancedParens) {
486+
// Matching # of closing parens was found, join all elements
487+
// from i to j
488+
finalSelectors.push(splitSelectors.slice(i, j + 1).join(','));
489+
490+
// we will want to skip the items that we have joined together
491+
i = j + 1;
492+
493+
// Use to continue the outer loop
494+
foundClosingSelector = true;
495+
496+
// break out of inner loop so we found matching closing parens
497+
break;
498+
}
499+
500+
// No matching closing parens found, keep moving through index, but
501+
// update the # of unbalanced parents still outstanding
502+
j++;
503+
unbalancedParens -= nextUnbalancedParens;
504+
}
505+
506+
if (foundClosingSelector) {
507+
// Matching closing selector was found, move to next selector
508+
continue;
509+
}
510+
511+
// No matching closing selector was found, either invalid CSS,
512+
// or unbalanced number of opening parens were used as CSS
513+
// selectors. Assume that rest of the list of selectors are
514+
// selectors and break to avoid iterating through the list of
515+
// selectors again.
516+
splitSelectors
517+
.slice(i, len)
518+
.forEach((selector) => selector && finalSelectors.push(selector));
519+
break;
520+
}
521+
522+
// No opening parens found, contiue looking through list
523+
splitSelectors[i] && finalSelectors.push(splitSelectors[i]);
524+
i++;
525+
}
526+
527+
return finalSelectors.map((s) => {
528+
return s.replace(/\u200C/g, ',');
529+
});
449530
}
450531

451532
/**

packages/rrweb-snapshot/test/css.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,87 @@ describe('css parser', () => {
119119
expect(out3).toEqual('[data-aa\\:other] { color: red; }');
120120
});
121121

122+
it.each([
123+
['.foo,.bar {}', ['.foo', '.bar']],
124+
['.bar:has(:disabled) {}', ['.bar:has(:disabled)']],
125+
['.bar:has(input, button) {}', ['.bar:has(input, button)']],
126+
[
127+
'.bar:has(input:is(:disabled),button:has(:disabled)) {}',
128+
['.bar:has(input:is(:disabled),button:has(:disabled))'],
129+
],
130+
[
131+
'.bar:has(div, input:is(:disabled), button) {}',
132+
['.bar:has(div,input:is(:disabled), button)'],
133+
],
134+
[
135+
'.bar:has(div, input:is(:disabled),button:has(:disabled,.baz)) {}',
136+
['.bar:has(div,input:is(:disabled),button:has(:disabled,.baz))'],
137+
],
138+
[
139+
'.bar:has(input), .foo:has(input, button), .baz {}',
140+
['.bar:has(input)', '.foo:has(input, button)', '.baz'],
141+
],
142+
[
143+
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
144+
[
145+
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
146+
],
147+
],
148+
['.bar((( {}', ['.bar(((']],
149+
[
150+
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button) {}',
151+
[
152+
'.bar:has(:has(:has(a),:has(:has(:has(b,:has(a), c), e))),input:is(:disabled), button)',
153+
],
154+
],
155+
['.foo,.bar(((,.baz {}', ['.foo', '.bar(((', '.baz']],
156+
[
157+
'.foo,.bar:has(input:is(:disabled)){color: red;}',
158+
['.foo', '.bar:has(input:is(:disabled))'],
159+
],
160+
[
161+
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz)){color: red;}',
162+
['.foo', '.bar:has(input:is(:disabled),button:has(:disabled,.baz))'],
163+
],
164+
[
165+
'.foo,.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz)){color: red;}',
166+
[
167+
'.foo',
168+
'.bar:has(input:is(:disabled),button:has(:disabled),div:has(:disabled,.baz))',
169+
],
170+
],
171+
[
172+
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
173+
[
174+
'.foo',
175+
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
176+
],
177+
],
178+
['.bar:has(:disabled), .foo {}', ['.bar:has(:disabled)', '.foo']],
179+
[
180+
'.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo {}',
181+
['.bar:has(input:is(:disabled),.foo,button:is(:disabled))', '.foo'],
182+
],
183+
[
184+
'.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo:has(input, button), .baz, {}',
185+
[
186+
'.bar:has(input:is(:disabled),.foo,button:is(:disabled))',
187+
'.foo:has(input, button)',
188+
'.baz',
189+
],
190+
],
191+
])(
192+
'can parse selector(s) with functional pseudo classes: %s',
193+
(cssText, expected) => {
194+
expect(
195+
parse(
196+
cssText,
197+
// @ts-ignore
198+
).stylesheet?.rules[0].selectors,
199+
).toEqual(expected);
200+
},
201+
);
202+
122203
it('parses imports with quotes correctly', () => {
123204
const out1 = escapeImportStatement({
124205
cssText: `@import url("/foo.css;900;800"");`,

0 commit comments

Comments
 (0)