Fix performance of splitCssText (#1615)
- Fix bug where the right split point was not being picked for the 3rd section onwards - Fix that it wasn't able to find a split when both halves were identical - Add test to put splitCssText through it's paces with a large file - Introduce a limit on the iteration which causes the 'efficiently' test to fail - Fix poor 'crawling' performance in the 'matching' algorithm for large css texts - e.g. for a (doubled) benchmark.css, we were running `normalizeCssText` 9480 times before `k` got to the right place - Further algorithm efficiency: need to take larger jumps; use the scaling factor to make better guess at how big a jump to make
This commit is contained in:
6
.changeset/efficiently-splitCssText-1603.md
Normal file
6
.changeset/efficiently-splitCssText-1603.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"rrweb-snapshot": patch
|
||||||
|
"rrweb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve performance of splitCssText for <style> elements with large css content - see #1603
|
||||||
@@ -456,8 +456,9 @@ export function normalizeCssString(cssText: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps the output of stringifyStylesheet to individual text nodes of a <style> element
|
* 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
|
* which occurs when javascript is used to append to the style element
|
||||||
* (javascript is needed to add extra text nodes to a <style>)
|
* 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(
|
export function splitCssText(
|
||||||
cssText: string,
|
cssText: string,
|
||||||
@@ -465,27 +466,69 @@ export function splitCssText(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const childNodes = Array.from(style.childNodes);
|
const childNodes = Array.from(style.childNodes);
|
||||||
const splits: string[] = [];
|
const splits: string[] = [];
|
||||||
|
let iterLimit = 0;
|
||||||
if (childNodes.length > 1 && cssText && typeof cssText === 'string') {
|
if (childNodes.length > 1 && cssText && typeof cssText === 'string') {
|
||||||
const cssTextNorm = normalizeCssString(cssText);
|
let cssTextNorm = normalizeCssString(cssText);
|
||||||
|
const normFactor = cssTextNorm.length / cssText.length;
|
||||||
for (let i = 1; i < childNodes.length; i++) {
|
for (let i = 1; i < childNodes.length; i++) {
|
||||||
if (
|
if (
|
||||||
childNodes[i].textContent &&
|
childNodes[i].textContent &&
|
||||||
typeof childNodes[i].textContent === 'string'
|
typeof childNodes[i].textContent === 'string'
|
||||||
) {
|
) {
|
||||||
const textContentNorm = normalizeCssString(childNodes[i].textContent!);
|
const textContentNorm = normalizeCssString(childNodes[i].textContent!);
|
||||||
for (let j = 3; j < textContentNorm.length; j++) {
|
let j = 3;
|
||||||
// find a substring that appears only once
|
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++) {
|
||||||
const bit = textContentNorm.substring(0, j);
|
const bit = textContentNorm.substring(0, j);
|
||||||
if (cssTextNorm.split(bit).length === 2) {
|
// this substring should appears only once in overall text too
|
||||||
const splitNorm = cssTextNorm.indexOf(bit);
|
const bits = cssTextNorm.split(bit);
|
||||||
|
let splitNorm = -1;
|
||||||
|
if (bits.length === 2) {
|
||||||
|
splitNorm = cssTextNorm.indexOf(bit);
|
||||||
|
} else if (
|
||||||
|
bits.length > 2 &&
|
||||||
|
bits[0] === '' &&
|
||||||
|
childNodes[i - 1].textContent !== ''
|
||||||
|
) {
|
||||||
|
// this childNode has same starting content as previous
|
||||||
|
splitNorm = cssTextNorm.indexOf(bit, 1);
|
||||||
|
}
|
||||||
|
if (splitNorm !== -1) {
|
||||||
// find the split point in the original text
|
// find the split point in the original text
|
||||||
for (let k = splitNorm; k < cssText.length; k++) {
|
let k = Math.floor(splitNorm / normFactor);
|
||||||
if (
|
for (; k > 0 && k < cssText.length; ) {
|
||||||
normalizeCssString(cssText.substring(0, k)).length === splitNorm
|
iterLimit += 1;
|
||||||
) {
|
if (iterLimit > 50 * childNodes.length) {
|
||||||
|
// quit for performance purposes
|
||||||
|
splits.push(cssText);
|
||||||
|
return splits;
|
||||||
|
}
|
||||||
|
const normPart = normalizeCssString(cssText.substring(0, k));
|
||||||
|
if (normPart.length === splitNorm) {
|
||||||
splits.push(cssText.substring(0, k));
|
splits.push(cssText.substring(0, k));
|
||||||
cssText = cssText.substring(k);
|
cssText = cssText.substring(k);
|
||||||
|
cssTextNorm = cssTextNorm.substring(splitNorm);
|
||||||
break;
|
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;
|
break;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import postcss, { type AcceptedPlugin } from 'postcss';
|
|||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
import { splitCssText, stringifyStylesheet } from './../src/utils';
|
import { splitCssText, stringifyStylesheet } from './../src/utils';
|
||||||
import { applyCssSplits } from './../src/rebuild';
|
import { applyCssSplits } from './../src/rebuild';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import type {
|
import type {
|
||||||
serializedElementNodeWithId,
|
serializedElementNodeWithId,
|
||||||
BuildCache,
|
BuildCache,
|
||||||
@@ -105,10 +107,16 @@ describe('css splitter', () => {
|
|||||||
// as authored, e.g. no spaces
|
// as authored, e.g. no spaces
|
||||||
style.append('.a{background-color:black;}');
|
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)
|
// how it is currently stringified (spaces present)
|
||||||
const expected = [
|
const expected = [
|
||||||
'.a { background-color: red; }',
|
'.a { background-color: red; }',
|
||||||
'.a { background-color: black; }',
|
'.a { background-color: black; }',
|
||||||
|
'.b { background-color: black; }',
|
||||||
|
'.c { background-color: black; }',
|
||||||
];
|
];
|
||||||
const browserSheet = expected.join('');
|
const browserSheet = expected.join('');
|
||||||
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet);
|
||||||
@@ -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', () => {
|
it('finds css textElement splits correctly when vendor prefixed rules have been removed', () => {
|
||||||
const style = JSDOM.fragment(`<style></style>`).querySelector('style');
|
const style = JSDOM.fragment(`<style></style>`).querySelector('style');
|
||||||
if (style) {
|
if (style) {
|
||||||
@@ -169,6 +199,34 @@ describe('css splitter', () => {
|
|||||||
expect(splitCssText(browserSheet, style)).toEqual(expected);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyCssSplits css rejoiner', function () {
|
describe('applyCssSplits css rejoiner', function () {
|
||||||
|
|||||||
@@ -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 { 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; }
|
.css-added-at-1000-deleted-at-2500 { display: flex; flex-direction: column; min-width: 60rem; min-height: 100vh; color: blue; }
|
||||||
|
|
||||||
@@ -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 { 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; }
|
.css-added-at-200.alt2 { padding-left: 4rem; }
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const events: eventWithTime[] = [
|
|||||||
tagName: 'style',
|
tagName: 'style',
|
||||||
attributes: {
|
attributes: {
|
||||||
_cssText:
|
_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',
|
'data-emotion': 'css',
|
||||||
},
|
},
|
||||||
childNodes: [
|
childNodes: [
|
||||||
|
|||||||
Reference in New Issue
Block a user