Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .changeset/efficiently-splitCssText-1603.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"rrweb-snapshot": patch
"rrweb": patch
---

Improve performance of splitCssText for <style> elements with large css content - see #1603
6 changes: 6 additions & 0 deletions .changeset/efficiently-splitCssText-1640.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"rrweb-snapshot": patch
"rrweb": patch
---

Improve performance of splitCssText for <style> elements with large css content - see #1603
120 changes: 105 additions & 15 deletions packages/rrweb-snapshot/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
* Browsers sometimes incorrectly escape `@import` on `.cssText` statements.
* This function tries to correct the escaping.
* more info: https://bugs.chromium.org/p/chromium/issues/detail?id=1472259
* @param cssImportRule

Check warning on line 83 in packages/rrweb-snapshot/src/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Check and Report Upload

tsdoc-param-tag-missing-hyphen: The @param block should be followed by a parameter name and then a hyphen

Check warning on line 83 in packages/rrweb-snapshot/src/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Check and Report Upload

tsdoc-param-tag-missing-hyphen: The @param block should be followed by a parameter name and then a hyphen
* @returns `cssText` with browser inconsistencies fixed, or null if not applicable.
*/
export function escapeImportStatement(rule: CSSImportRule): string {
Expand Down Expand Up @@ -450,42 +450,132 @@
* Intention is to normalize by remove spaces, semicolons and CSS comments
* so that we can compare css as authored vs. output of stringifyStylesheet
*/
export function normalizeCssString(cssText: string): string {
return cssText.replace(/(\/\*[^*]*\*\/)|[\s;]/g, '');
export function normalizeCssString(
cssText: string,
/**
* _testNoPxNorm: only used as part of the 'substring matching going from many to none'
* test case so that it will trigger a failure if the conditions that let to the creation of that test arise again
*/
_testNoPxNorm = false,
): string {
if (_testNoPxNorm) {
return cssText.replace(/(\/\*[^*]*\*\/)|[\s;]/g, '');
} else {
return cssText.replace(/(\/\*[^*]*\*\/)|[\s;]/g, '').replace(/0px/g, '0');
}
}

/**
* Maps the output of stringifyStylesheet to individual text nodes of a <style> element
* performance is not considered as this is anticipated to be very much an edge case
* (javascript is needed to add extra text nodes to a <style>)
* which occurs when javascript is used to append to the style element
* and may also occur when browsers opt to break up large text nodes
* performance needs to be considered, see e.g. #1603
*/
export function splitCssText(
cssText: string,
style: HTMLStyleElement,
_testNoPxNorm = false,
): string[] {
const childNodes = Array.from(style.childNodes);
const splits: string[] = [];
let iterCount = 0;
if (childNodes.length > 1 && cssText && typeof cssText === 'string') {
const cssTextNorm = normalizeCssString(cssText);
let cssTextNorm = normalizeCssString(cssText, _testNoPxNorm);
const normFactor = cssTextNorm.length / cssText.length;
for (let i = 1; i < childNodes.length; i++) {
if (
childNodes[i].textContent &&
typeof childNodes[i].textContent === 'string'
) {
const textContentNorm = normalizeCssString(childNodes[i].textContent!);
for (let j = 3; j < textContentNorm.length; j++) {
// find a substring that appears only once
const bit = textContentNorm.substring(0, j);
if (cssTextNorm.split(bit).length === 2) {
const splitNorm = cssTextNorm.indexOf(bit);
const textContentNorm = normalizeCssString(
childNodes[i].textContent!,

Check warning on line 491 in packages/rrweb-snapshot/src/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Check and Report Upload

Forbidden non-null assertion

Check warning on line 491 in packages/rrweb-snapshot/src/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Check and Report Upload

Forbidden non-null assertion
_testNoPxNorm,
);
const jLimit = 100; // how many iterations for the first part of searching
let j = 3;
for (; j < textContentNorm.length; j++) {
if (
// keep consuming css identifiers (to get a decent chunk more quickly)
textContentNorm[j].match(/[a-zA-Z0-9]/) ||
// substring needs to be unique to this section
textContentNorm.indexOf(textContentNorm.substring(0, j), 1) !== -1
) {
continue;
}
break;
}
for (; j < textContentNorm.length; j++) {
let startSubstring = textContentNorm.substring(0, j);
// this substring should appears only once in overall text too
let cssNormSplits = cssTextNorm.split(startSubstring);
let splitNorm = -1;
if (cssNormSplits.length === 2) {
splitNorm = cssNormSplits[0].length;
} else if (
cssNormSplits.length > 2 &&
cssNormSplits[0] === '' &&
childNodes[i - 1].textContent !== ''
) {
// this childNode has same starting content as previous
splitNorm = cssTextNorm.indexOf(startSubstring, 1);
} else if (cssNormSplits.length === 1) {
// try to roll back to get multiple matches again
startSubstring = startSubstring.substring(
0,
startSubstring.length - 1,
);
cssNormSplits = cssTextNorm.split(startSubstring);
if (cssNormSplits.length <= 1) {
// no split possible
splits.push(cssText);
return splits;
}
j = jLimit + 1; // trigger end of search
} else if (j === textContentNorm.length - 1) {
// we're about to end loop without a split point
splitNorm = cssTextNorm.indexOf(startSubstring);
}
if (cssNormSplits.length >= 2 && j > jLimit) {
const prevTextContent = childNodes[i - 1].textContent;
if (prevTextContent && typeof prevTextContent === 'string') {
// pick the first matching point which respects the previous chunk's approx size
const prevMinLength = normalizeCssString(prevTextContent).length;
splitNorm = cssTextNorm.indexOf(startSubstring, prevMinLength);
}
if (splitNorm === -1) {
// fall back to pick the first matching point of many
splitNorm = cssNormSplits[0].length;
}
}
if (splitNorm !== -1) {
// find the split point in the original text
for (let k = splitNorm; k < cssText.length; k++) {
if (
normalizeCssString(cssText.substring(0, k)).length === splitNorm
) {
let k = Math.floor(splitNorm / normFactor);
for (; k > 0 && k < cssText.length; ) {
iterCount += 1;
if (iterCount > 50 * childNodes.length) {
// quit for performance purposes
splits.push(cssText);
return splits;
}
const normPart = normalizeCssString(
cssText.substring(0, k),
_testNoPxNorm,
);
if (normPart.length === splitNorm) {
splits.push(cssText.substring(0, k));
cssText = cssText.substring(k);
cssTextNorm = cssTextNorm.substring(splitNorm);
break;
} else if (normPart.length < splitNorm) {
k += Math.max(
1,
Math.floor((splitNorm - normPart.length) / normFactor),
);
} else {
k -= Math.max(
1,
Math.floor((normPart.length - splitNorm) * normFactor),
);
}
}
break;
Expand Down
142 changes: 141 additions & 1 deletion packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import postcss, { type AcceptedPlugin } from 'postcss';
import { JSDOM } from 'jsdom';
import { splitCssText, stringifyStylesheet } from './../src/utils';
import { applyCssSplits } from './../src/rebuild';
import * as fs from 'fs';
import * as path from 'path';
import type {
serializedElementNodeWithId,
BuildCache,
Expand Down Expand Up @@ -105,10 +107,16 @@ describe('css splitter', () => {
// as authored, e.g. no spaces
style.append('.a{background-color:black;}');

// test how normalization finds the right sections
style.append('.b {background-color:black;}');
style.append('.c{ background-color: black}');

// how it is currently stringified (spaces present)
const expected = [
'.a { background-color: red; }',
'.a { background-color: black; }',
'.b { background-color: black; }',
'.c { background-color: black; }',
];
const browserSheet = expected.join('');
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
Expand Down Expand Up @@ -137,6 +145,28 @@ describe('css splitter', () => {
}
});

it('finds css textElement splits correctly with two identical text nodes', () => {
const window = new Window({ url: 'https://localhost:8080' });
const document = window.document;
// as authored, with comment, missing semicolons
const textContent = '.a { color:red; } .b { color:blue; }';
document.head.innerHTML = '<style></style>';
const style = document.querySelector('style');
if (style) {
style.append(textContent);
style.append(textContent);

const expected = [textContent, textContent];
const browserSheet = expected.join('');
expect(splitCssText(browserSheet, style)).toEqual(expected);

style.append(textContent);
const expected3 = [textContent, textContent, textContent];
const browserSheet3 = expected3.join('');
expect(splitCssText(browserSheet3, style)).toEqual(expected3);
}
});

it('finds css textElement splits correctly when vendor prefixed rules have been removed', () => {
const style = JSDOM.fragment(`<style></style>`).querySelector('style');
if (style) {
Expand All @@ -148,7 +178,6 @@ describe('css splitter', () => {
transition: all 4s ease;
}`),
);
// TODO: splitCssText can't handle it yet if both start with .x
style.appendChild(
JSDOM.fragment(`.y {
-moz-transition: all 5s ease;
Expand All @@ -169,6 +198,117 @@ describe('css splitter', () => {
expect(splitCssText(browserSheet, style)).toEqual(expected);
}
});

it('efficiently finds split points in large files', () => {
const cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);

const parts = cssText.split('}');
const sections = [];
for (let i = 0; i < parts.length - 1; i++) {
if (i % 100 === 0) {
sections.push(parts[i] + '}');
} else {
sections[sections.length - 1] += parts[i] + '}';
}
}
sections[sections.length - 1] += parts[parts.length - 1];

expect(cssText.length).toEqual(sections.join('').length);

const style = JSDOM.fragment(`<style></style>`).querySelector('style');
if (style) {
sections.forEach((section) => {
style.appendChild(JSDOM.fragment(section));
});
}
expect(splitCssText(cssText, style)).toEqual(sections);
});

it('finds css textElement splits correctly, with substring matching going from many to none', () => {
const window = new Window({ url: 'https://localhost:8080' });
const document = window.document;
document.head.innerHTML = `<style>
.section-news-v3-detail .news-cnt-wrapper :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0px;
margin-bottom: 0px;
}

.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(figure):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
margin-top: 2em;
margin-bottom: 2em;
}

.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(.prose > :first-child):not(:where([class~="not-prose"],[cl</style>`;
const style = document.querySelector('style');
if (style) {
// happydom? bug avoid: strangely a greater than symbol in the template string below
// e.g. '.prose > :last-child' causes more than one child to be appended
style.append(`ass~="not-prose"] *)) {
margin-top: 0; /* cssRules transforms this to '0px' which was preventing matching prior to normalization */
}

.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(.prose :last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
margin-bottom: 0;
}

.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 {
width: 100%;
overflow-wrap: break-word;
}

.section-home {
height: 100%;
overflow-y: auto;
}
`);

expect(style.childNodes.length).toEqual(2);

const expected = [
'.section-news-v3-detail .news-cnt-wrapper :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 0px; margin-bottom: 0px; }.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(figure):not(:where([class~="not-prose"],[class~="not-prose"] *)) { margin-top: 2em; margin-bottom: 2em; }.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(.prose > :first-child):not(:where([class~="not-prose"],[cl',
'ass~="not-prose"] *)) { margin-top: 0px; }.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 :where(.prose :last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { margin-bottom: 0px; }.section-news-v3-detail .news-cnt-wrapper .plugins-wrapper2 { width: 100%; overflow-wrap: break-word; }.section-home { height: 100%; overflow-y: auto; }',
];
const browserSheet = expected.join('');
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
let _testNoPxNorm = true; // trigger the original motivating scenario for this test
expect(splitCssText(browserSheet, style, _testNoPxNorm)).toEqual(
expected,
);
_testNoPxNorm = false; // this case should also be solved by normalizing '0px' -> '0'
expect(splitCssText(browserSheet, style, _testNoPxNorm)).toEqual(
expected,
);
}
});

it('finds css textElement splits correctly, even with repeated sections', () => {
const window = new Window({ url: 'https://localhost:8080' });
const document = window.document;
document.head.innerHTML =
'<style>.a{background-color: black; } </style>';
const style = document.querySelector('style');
if (style) {
style.append('.x{background-color:red;}');
style.append('.b {background-color:black;}');
style.append('.x{background-color:red;}');
style.append('.c{ background-color: black}');

const expected = [
'.a { background-color: black; }',
'.x { background-color: red; }',
'.b { background-color: black; }',
'.x { background-color: red; }',
'.c { background-color: black; }',
];
const browserSheet = expected.join('');
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);

expect(splitCssText(browserSheet, style)).toEqual(expected);
}
});
});

describe('applyCssSplits css rejoiner', function () {
Expand Down
4 changes: 2 additions & 2 deletions packages/rrweb/test/__snapshots__/replayer.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ file-cid-3

.css-added-at-200 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }

.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease-in 0.1s; }

.css-added-at-1000-deleted-at-2500 { display: flex; flex-direction: column; min-width: 60rem; min-height: 100vh; color: blue; }

Expand Down Expand Up @@ -152,7 +152,7 @@ file-cid-3

.css-added-at-200 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }

.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease-in 0.1s; }

.css-added-at-200.alt2 { padding-left: 4rem; }
"
Expand Down
2 changes: 1 addition & 1 deletion packages/rrweb/test/events/style-sheet-rule-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const events: eventWithTime[] = [
tagName: 'style',
attributes: {
_cssText:
'.css-added-at-200 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }.css-added-at-200.alt2 { padding-left: 4rem; }',
'.css-added-at-200 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease-in 0.1s; }.css-added-at-200.alt2 { padding-left: 4rem; }',
'data-emotion': 'css',
},
childNodes: [
Expand Down
Loading