From 810b39f6fa62d17f2389467121ddd11ae6aa1033 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 25 Mar 2024 14:49:56 -0230 Subject: [PATCH] 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 --- packages/rrweb-snapshot/src/css.ts | 87 +++++++++++++++++++++++- packages/rrweb-snapshot/test/css.test.ts | 81 ++++++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index d7a413eb67..41a66f8724 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -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, ','); + }); } /** diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 2818386071..d6731db131 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -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"");`,