Speed up snapshotting of many new dom nodes (#903)

* Speed up snapshotting of many new dom nodes

By avoiding reflow we shave about 15-25% off our snapshotting time

* Improve newlyAddedElement docs

* Optimize needMaskingText by using el.closest and less recursion

* Serve all rrweb dist files

* Split serializeNode into smaller functions

Makes it easier to profile

* Slow down cpu enhance tracing on fast machines

* Increase timeout

* Perf: only loop through ancestors when they have something to compare to

* Perf: `hasNode` is cheaper than `getMeta`

* Perf: If parents where already checked, no need to do it again

* Perf: reverse for loops are faster

Because they only do the .lenght check once. In this case I don't think we'll see much performance gains if any

* Clean up code

* Perf: check ancestors once with isBlocked

* guessing this might fixes canvas test

* Update packages/rrweb/src/record/observers/canvas/webgl.ts

Co-authored-by: yz-yu <yanzhen@smartx.com>

* Fix #904 (#906)

Properly remove crossorigin attribute

* Bump minimist from 1.2.5 to 1.2.6 (#902)

Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

Co-authored-by: yz-yu <yanzhen@smartx.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent ef0ff2fe3b
commit 65338aaf11
22 changed files with 815 additions and 372 deletions

View File

@@ -1131,6 +1131,126 @@ exports[`record is safe to checkout during async callbacks 1`] = `
]"
`;
exports[`record should record scroll position 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"p\\",
\\"attributes\\": {
\\"style\\": \\"overflow: auto; height: Npx; width: Npx;\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"testtesttesttesttesttesttesttesttesttest\\",
\\"id\\": 10
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 3,
\\"id\\": 9,
\\"x\\": 10,
\\"y\\": 10
}
}
]"
`;
exports[`record without CSSGroupingRule support captures nested stylesheet rules 1`] = `
"[
{

View File

@@ -1,15 +1,43 @@
// tslint:disable:no-console no-any
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from 'puppeteer';
import type { eventWithTime, recordOptions } from '../../src/types';
import { startServer, launchPuppeteer, replaceLast, ISuite } from '../utils';
import { startServer, launchPuppeteer, ISuite, getServerURL } from '../utils';
const suites: Array<
{
title: string;
eval: string;
times?: number; // defaults to 5
} & ({ html: string } | { url: string })
> = [
// {
// title: 'benchmarking external website',
// url: 'http://localhost:5050',
// eval: 'document.querySelector("button").click()',
// times: 10,
// },
{
title: 'create 1000x10 DOM nodes',
html: 'benchmark-dom-mutation.html',
eval: 'window.workload()',
times: 10,
},
{
title: 'create 1000x10x2 DOM nodes and remove a bunch of them',
html: 'benchmark-dom-mutation-add-and-remove.html',
eval: 'window.workload()',
times: 10,
},
];
function avg(v: number[]): number {
return v.reduce((prev, cur) => prev + cur, 0) / v.length;
}
describe('benchmark: mutation observer', () => {
let code: ISuite['code'];
jest.setTimeout(240000);
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
@@ -20,9 +48,6 @@ describe('benchmark: mutation observer', () => {
dumpio: true,
headless: true,
});
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
@@ -36,30 +61,19 @@ describe('benchmark: mutation observer', () => {
const getHtml = (fileName: string): string => {
const filePath = path.resolve(__dirname, `../html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return replaceLast(
html,
'</body>',
`
<script>
${code}
</script>
</body>
`,
);
return fs.readFileSync(filePath, 'utf8');
};
const suites: {
title: string;
html: string;
times?: number; // default to 5
}[] = [
{
title: 'create 1000x10 DOM nodes',
html: 'benchmark-dom-mutation.html',
times: 10,
},
];
const addRecordingScript = async (page: Page) => {
// const scriptUrl = `${getServerURL(server)}/rrweb-1.1.3.js`;
const scriptUrl = `${getServerURL(server)}/rrweb.js`;
await page.evaluate((url) => {
const scriptEl = document.createElement('script');
scriptEl.src = url;
document.head.append(scriptEl);
}, scriptUrl);
await page.waitForFunction('window.rrweb');
};
for (const suite of suites) {
it(suite.title, async () => {
@@ -68,12 +82,19 @@ describe('benchmark: mutation observer', () => {
console.log(`${message.type().toUpperCase()} ${message.text()}`),
);
const times = suite.times ?? 5;
const durations: number[] = [];
for (let i = 0; i < times; i++) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, suite.html));
const duration = (await page.evaluate(() => {
const loadPage = async () => {
if ('html' in suite) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, suite.html));
} else {
await page.goto(suite.url);
}
await addRecordingScript(page);
};
const getDuration = async (): Promise<number> => {
return (await page.evaluate((triggerWorkloadScript) => {
return new Promise((resolve, reject) => {
let start = 0;
let lastEvent: eventWithTime | null;
@@ -94,14 +115,55 @@ describe('benchmark: mutation observer', () => {
const record = (window as any).rrweb.record;
record(options);
(window as any).workload();
start = Date.now();
setTimeout(() => {
eval(triggerWorkloadScript);
requestAnimationFrame(() => {
record.addCustomEvent('FTAG', {});
}, 0);
});
});
})) as number;
}, suite.eval)) as number;
};
// generate profile.json file
const profileFilename = `profile-${new Date().toISOString()}.json`;
const tempDirectory = path.resolve(path.join(__dirname, '../../temp'));
fs.mkdirSync(tempDirectory, { recursive: true });
const profilePath = path.resolve(tempDirectory, profileFilename);
const client = await page.target().createCDPSession();
await client.send('Emulation.setCPUThrottlingRate', { rate: 6 });
await page.tracing.start({
path: profilePath,
screenshots: true,
categories: [
'-*',
'devtools.timeline',
'v8.execute',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'toplevel',
'blink.console',
'blink.user_timing',
'latencyInfo',
'disabled-by-default-devtools.timeline.stack',
'disabled-by-default-v8.cpu_profiler',
'disabled-by-default-v8.cpu_profiler.hires',
],
});
await loadPage();
await getDuration();
await page.waitForTimeout(1000);
await page.tracing.stop();
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
// calculate durations
const times = suite.times ?? 5;
const durations: number[] = [];
for (let i = 0; i < times; i++) {
await loadPage();
const duration = await getDuration();
durations.push(duration);
}
@@ -112,6 +174,7 @@ describe('benchmark: mutation observer', () => {
durations: durations.join(', '),
},
]);
console.log('profile: ', profilePath);
});
}
});

View File

@@ -0,0 +1,46 @@
<html>
<body></body>
<script>
function add() {
const branches = 1000;
const depth = 10;
const frag = document.createDocumentFragment();
for (let b = 0; b < branches; b++) {
const node = document.createElement('div');
let d = 0;
node.setAttribute('branch', b.toString());
node.setAttribute('depth', d.toString());
let current = node;
while (d < depth - 1) {
d++;
const child = document.createElement('div');
child.setAttribute('branch', b.toString());
child.setAttribute('depth', d.toString());
current.appendChild(child);
current = child;
}
frag.appendChild(node);
}
document.body.appendChild(frag);
}
function remove() {
// const divs = Array.from(document.querySelectorAll('div'));
// const half = divs.length / 2;
// while (divs.length > half) {
// const i = (divs.length * Math.random()) | 0;
// divs[i].remove();
// divs.splice(i, 1);
// }
document.querySelectorAll('div').forEach((node) => {
node.parentNode.removeChild(node);
});
}
window.workload = () => {
add();
remove();
add();
};
</script>
</html>

View File

@@ -192,6 +192,23 @@ describe('record', function (this: ISuite) {
assertSnapshot(ctx.events);
});
it('should record scroll position', async () => {
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
const p = document.createElement('p');
p.innerText = 'testtesttesttesttesttesttesttesttesttest';
p.setAttribute('style', 'overflow: auto; height: 1px; width: 1px;');
document.body.appendChild(p);
p.scrollTop = 10;
p.scrollLeft = 10;
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('can add custom event', async () => {
await ctx.page.evaluate(() => {
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;

View File

@@ -56,7 +56,11 @@ export const startServer = (defaultPort: number = 3030) =>
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
const pathname = path.join(__dirname, sanitizePath);
let pathname = path.join(__dirname, sanitizePath);
if (/^\/rrweb.*\.js.*/.test(sanitizePath)) {
pathname = path.join(__dirname, `../dist`, sanitizePath);
}
try {
const data = fs.readFileSync(pathname);