Skip to content

Commit 128f3f8

Browse files
daibhineoghanmurray
authored andcommitted
better splitting of selectors (rrweb-io#1440)
* better splitting of selectors - overlapping with rrweb-io#1401 * Add test from example at PostHog/posthog#21427 * ignore brackets inside selector strings * Add another test as noticed that it's possible to escape strings * Ensure we are ignoring commas within strings Co-authored-by: Eoghan Murray <[email protected]>
1 parent 9e07245 commit 128f3f8

File tree

3 files changed

+103
-87
lines changed

3 files changed

+103
-87
lines changed

.changeset/modern-doors-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'rrweb-snapshot': patch
3+
---
4+
5+
better nested css selector splitting when commas or brackets happen to be in quoted text

packages/rrweb-snapshot/src/css.ts

Lines changed: 48 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -435,98 +435,67 @@ export function parse(css: string, options: ParserOptions = {}) {
435435
if (!m) {
436436
return;
437437
}
438+
438439
/* @fix Remove all comments from selectors
439440
* http://ostermiller.org/findcomment.html */
440-
const splitSelectors = trim(m[0])
441+
const cleanedInput = m[0]
442+
.trim()
441443
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
444+
// Handle strings by replacing commas inside them
442445
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
443446
return m.replace(/,/g, '\u200C');
444-
})
445-
.split(/\s*(?![^(]*\)),\s*/);
446-
447-
if (splitSelectors.length <= 1) {
448-
return splitSelectors.map((s) => {
449-
return s.replace(/\u200C/g, ',');
450447
});
451-
}
452448

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-
}
449+
// Split using a custom function and restore commas in strings
450+
return customSplit(cleanedInput).map((s) =>
451+
s.replace(/\u200C/g, ',').trim(),
452+
);
453+
}
505454

506-
if (foundClosingSelector) {
507-
// Matching closing selector was found, move to next selector
508-
continue;
509-
}
455+
/**
456+
* Split selector correctly, ensuring not to split on comma if inside ().
457+
*/
458+
459+
function customSplit(input: string) {
460+
const result = [];
461+
let currentSegment = '';
462+
let depthParentheses = 0; // Track depth of parentheses
463+
let depthBrackets = 0; // Track depth of square brackets
464+
let currentStringChar = null;
510465

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;
466+
for (const char of input) {
467+
const hasStringEscape = currentSegment.endsWith('\\');
468+
469+
if (currentStringChar) {
470+
if (currentStringChar === char && !hasStringEscape) {
471+
currentStringChar = null;
472+
}
473+
} else if (char === '(') {
474+
depthParentheses++;
475+
} else if (char === ')') {
476+
depthParentheses--;
477+
} else if (char === '[') {
478+
depthBrackets++;
479+
} else if (char === ']') {
480+
depthBrackets--;
481+
} else if ('\'"'.includes(char)) {
482+
currentStringChar = char;
520483
}
521484

522-
// No opening parens found, contiue looking through list
523-
splitSelectors[i] && finalSelectors.push(splitSelectors[i]);
524-
i++;
485+
// Split point is a comma that is not inside parentheses or square brackets
486+
if (char === ',' && depthParentheses === 0 && depthBrackets === 0) {
487+
result.push(currentSegment);
488+
currentSegment = '';
489+
} else {
490+
currentSegment += char;
491+
}
525492
}
526493

527-
return finalSelectors.map((s) => {
528-
return s.replace(/\u200C/g, ',');
529-
});
494+
// Add the last segment
495+
if (currentSegment) {
496+
result.push(currentSegment);
497+
}
498+
return result;
530499
}
531500

532501
/**

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

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

122+
it('parses nested commas in selectors correctly', () => {
123+
const result = parse(
124+
`
125+
body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {
126+
background: red;
127+
}
128+
`,
129+
);
130+
expect((result.stylesheet!.rules[0] as Rule)!.selectors!.length).toEqual(1);
131+
132+
const trickresult = parse(
133+
`
134+
li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {
135+
background-color: red;
136+
}
137+
`,
138+
);
139+
expect(
140+
(trickresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
141+
).toEqual(2);
142+
143+
const weirderresult = parse(
144+
`
145+
li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {
146+
background-color: red;
147+
}
148+
`,
149+
);
150+
expect(
151+
(weirderresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
152+
).toEqual(2);
153+
154+
const commainstrresult = parse(
155+
`
156+
li[attr="has,comma"] a:hover {
157+
background-color: red;
158+
}
159+
`,
160+
);
161+
expect(
162+
(commainstrresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
163+
).toEqual(1);
164+
});
165+
122166
it.each([
123167
['.foo,.bar {}', ['.foo', '.bar']],
124168
['.bar:has(:disabled) {}', ['.bar:has(:disabled)']],
@@ -129,11 +173,11 @@ describe('css parser', () => {
129173
],
130174
[
131175
'.bar:has(div, input:is(:disabled), button) {}',
132-
['.bar:has(div,input:is(:disabled), button)'],
176+
['.bar:has(div, input:is(:disabled), button)'],
133177
],
134178
[
135179
'.bar:has(div, input:is(:disabled),button:has(:disabled,.baz)) {}',
136-
['.bar:has(div,input:is(:disabled),button:has(:disabled,.baz))'],
180+
['.bar:has(div, input:is(:disabled),button:has(:disabled,.baz))'],
137181
],
138182
[
139183
'.bar:has(input), .foo:has(input, button), .baz {}',
@@ -142,17 +186,15 @@ describe('css parser', () => {
142186
[
143187
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
144188
[
145-
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
189+
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))',
146190
],
147191
],
148-
['.bar((( {}', ['.bar(((']],
149192
[
150193
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button) {}',
151194
[
152-
'.bar:has(:has(:has(a),:has(:has(:has(b,:has(a), c), e))),input:is(:disabled), button)',
195+
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button)',
153196
],
154197
],
155-
['.foo,.bar(((,.baz {}', ['.foo', '.bar(((', '.baz']],
156198
[
157199
'.foo,.bar:has(input:is(:disabled)){color: red;}',
158200
['.foo', '.bar:has(input:is(:disabled))'],
@@ -165,14 +207,14 @@ describe('css parser', () => {
165207
'.foo,.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz)){color: red;}',
166208
[
167209
'.foo',
168-
'.bar:has(input:is(:disabled),button:has(:disabled),div:has(:disabled,.baz))',
210+
'.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz))',
169211
],
170212
],
171213
[
172214
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
173215
[
174216
'.foo',
175-
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
217+
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))',
176218
],
177219
],
178220
['.bar:has(:disabled), .foo {}', ['.bar:has(:disabled)', '.foo']],

0 commit comments

Comments
 (0)