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

@@ -16,6 +16,8 @@ module.exports = {
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'],
},
plugins: ['@typescript-eslint'],
rules: {},
plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc'],
rules: {
'tsdoc/syntax': 'warn',
},
};

View File

@@ -25,6 +25,7 @@
"@typescript-eslint/parser": "^5.25.0",
"concurrently": "^7.1.0",
"eslint": "^8.15.0",
"eslint-plugin-tsdoc": "^0.2.16",
"lerna": "^4.0.0",
"markdownlint": "^0.25.1",
"markdownlint-cli": "^0.31.1",

View File

@@ -4,6 +4,7 @@ import snapshot, {
visitSnapshot,
cleanupSnapshot,
needMaskingText,
classMatchesRegex,
IGNORED_NODE,
} from './snapshot';
import rebuild, {
@@ -25,5 +26,6 @@ export {
visitSnapshot,
cleanupSnapshot,
needMaskingText,
classMatchesRegex,
IGNORED_NODE,
};

View File

@@ -268,8 +268,7 @@ export function _isBlockedElement(
return true;
}
} else {
// tslint:disable-next-line: prefer-for-of
for (let eIndex = 0; eIndex < element.classList.length; eIndex++) {
for (let eIndex = element.classList.length; eIndex--; ) {
const className = element.classList[eIndex];
if (blockClass.test(className)) {
return true;
@@ -283,44 +282,50 @@ export function _isBlockedElement(
return false;
}
export function needMaskingText(
export function classMatchesRegex(
node: Node | null,
regex: RegExp,
checkAncestors: boolean,
): boolean {
if (!node) return false;
if (node.nodeType !== node.ELEMENT_NODE) {
if (!checkAncestors) return false;
return classMatchesRegex(node.parentNode, regex, checkAncestors);
}
for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) {
const className = (node as HTMLElement).classList[eIndex];
if (regex.test(className)) {
return true;
}
}
if (!checkAncestors) return false;
return classMatchesRegex(node.parentNode, regex, checkAncestors);
}
export function needMaskingText(
node: Node,
maskTextClass: string | RegExp,
maskTextSelector: string | null,
): boolean {
if (!node) {
return false;
}
if (node.nodeType === node.ELEMENT_NODE) {
const el: HTMLElement | null =
node.nodeType === node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
if (el === null) return false;
if (typeof maskTextClass === 'string') {
if ((node as HTMLElement).classList.contains(maskTextClass)) {
return true;
}
if (el.classList.contains(maskTextClass)) return true;
if (el.closest(`.${maskTextClass}`)) return true;
} else {
// tslint:disable-next-line: prefer-for-of
for (
let eIndex = 0;
eIndex < (node as HTMLElement).classList.length;
eIndex++
) {
const className = (node as HTMLElement).classList[eIndex];
if (maskTextClass.test(className)) {
return true;
}
}
if (classMatchesRegex(el, maskTextClass, true)) return true;
}
if (maskTextSelector) {
if ((node as HTMLElement).matches(maskTextSelector)) {
return true;
if (el.matches(maskTextSelector)) return true;
if (el.closest(maskTextSelector)) return true;
}
}
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
}
if (node.nodeType === node.TEXT_NODE) {
// check parent node since text node do not have class name
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
}
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
return false;
}
// https://stackoverflow.com/a/36155560
@@ -389,6 +394,10 @@ function serializeNode(
inlineImages: boolean;
recordCanvas: boolean;
keepIframeSrcFn: KeepIframeSrcFn;
/**
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
*/
newlyAddedElement?: boolean;
},
): serializedNode | false {
const {
@@ -406,20 +415,17 @@ function serializeNode(
inlineImages,
recordCanvas,
keepIframeSrcFn,
newlyAddedElement = false,
} = options;
// Only record root id when document object is not the base document
let rootId: number | undefined;
if (mirror.getMeta(doc)) {
const docId = mirror.getId(doc);
rootId = docId === 1 ? undefined : docId;
}
const rootId = getRootId(doc, mirror);
switch (n.nodeType) {
case n.DOCUMENT_NODE:
if ((n as HTMLDocument).compatMode !== 'CSS1Compat') {
if ((n as Document).compatMode !== 'CSS1Compat') {
return {
type: NodeType.Document,
childNodes: [],
compatMode: (n as HTMLDocument).compatMode, // probably "BackCompat"
compatMode: (n as Document).compatMode, // probably "BackCompat"
rootId,
};
} else {
@@ -438,16 +444,149 @@ function serializeNode(
rootId,
};
case n.ELEMENT_NODE:
const needBlock = _isBlockedElement(
n as HTMLElement,
return serializeElementNode(n as HTMLElement, {
doc,
blockClass,
blockSelector,
inlineStylesheet,
maskInputOptions,
maskInputFn,
dataURLOptions,
inlineImages,
recordCanvas,
keepIframeSrcFn,
newlyAddedElement,
rootId,
});
case n.TEXT_NODE:
return serializeTextNode(n as Text, {
maskTextClass,
maskTextSelector,
maskTextFn,
rootId,
});
case n.CDATA_SECTION_NODE:
return {
type: NodeType.CDATA,
textContent: '',
rootId,
};
case n.COMMENT_NODE:
return {
type: NodeType.Comment,
textContent: (n as Comment).textContent || '',
rootId,
};
default:
return false;
}
}
function getRootId(doc: Document, mirror: Mirror): number | undefined {
if (!mirror.hasNode(doc)) return undefined;
const docId = mirror.getId(doc);
return docId === 1 ? undefined : docId;
}
function serializeTextNode(
n: Text,
options: {
maskTextClass: string | RegExp;
maskTextSelector: string | null;
maskTextFn: MaskTextFn | undefined;
rootId: number | undefined;
},
): serializedNode {
const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
// The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
let textContent = n.textContent;
const isStyle = parentTagName === 'STYLE' ? true : undefined;
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && textContent) {
try {
// try to read style sheet
if (n.nextSibling || n.previousSibling) {
// This is not the only child of the stylesheet.
// We can't read all of the sheet's .cssRules and expect them
// to _only_ include the current rule(s) added by the text node.
// So we'll be conservative and keep textContent as-is.
} else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) {
textContent = stringifyStyleSheet(
(n.parentNode as HTMLStyleElement).sheet!,
);
const tagName = getValidTagName(n as HTMLElement);
}
} catch (err) {
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err}`,
n,
);
}
textContent = absoluteToStylesheet(textContent, getHref());
}
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
}
if (
!isStyle &&
!isScript &&
textContent &&
needMaskingText(n, maskTextClass, maskTextSelector)
) {
textContent = maskTextFn
? maskTextFn(textContent)
: textContent.replace(/[\S]/g, '*');
}
return {
type: NodeType.Text,
textContent: textContent || '',
isStyle,
rootId,
};
}
function serializeElementNode(
n: HTMLElement,
options: {
doc: Document;
blockClass: string | RegExp;
blockSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskInputFn: MaskInputFn | undefined;
dataURLOptions?: DataURLOptions;
inlineImages: boolean;
recordCanvas: boolean;
keepIframeSrcFn: KeepIframeSrcFn;
/**
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
*/
newlyAddedElement?: boolean;
rootId: number | undefined;
},
): serializedNode | false {
const {
doc,
blockClass,
blockSelector,
inlineStylesheet,
maskInputOptions = {},
maskInputFn,
dataURLOptions = {},
inlineImages,
recordCanvas,
keepIframeSrcFn,
newlyAddedElement = false,
rootId,
} = options;
const needBlock = _isBlockedElement(n, blockClass, blockSelector);
const tagName = getValidTagName(n);
let attributes: attributes = {};
const len = (n as HTMLElement).attributes.length;
const len = n.attributes.length;
for (let i = 0; i < len; i++) {
const attr = (n as HTMLElement).attributes[i];
const attr = n.attributes[i];
attributes[attr.name] = transformAttribute(
doc,
tagName,
@@ -462,15 +601,12 @@ function serializeNode(
});
let cssText: string | null = null;
if (stylesheet) {
cssText = getCssRulesString(stylesheet );
cssText = getCssRulesString(stylesheet);
}
if (cssText) {
delete attributes.rel;
delete attributes.href;
attributes._cssText = absoluteToStylesheet(
cssText,
stylesheet!.href!,
);
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
}
}
// dynamic stylesheet
@@ -478,11 +614,7 @@ function serializeNode(
tagName === 'style' &&
(n as HTMLStyleElement).sheet &&
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
!(
(n as HTMLElement).innerText ||
(n as HTMLElement).textContent ||
''
).trim().length
!(n.innerText || n.textContent || '').trim().length
) {
const cssText = getCssRulesString(
(n as HTMLStyleElement).sheet as CSSStyleSheet,
@@ -492,11 +624,7 @@ function serializeNode(
}
}
// form fields
if (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select'
) {
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
if (
attributes.type !== 'radio' &&
@@ -595,16 +723,22 @@ function serializeNode(
: 'played';
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
}
// scroll
if ((n as HTMLElement).scrollLeft) {
attributes.rr_scrollLeft = (n as HTMLElement).scrollLeft;
// Scroll
if (!newlyAddedElement) {
// `scrollTop` and `scrollLeft` are expensive calls because they trigger reflow.
// Since `scrollTop` & `scrollLeft` are always 0 when an element is added to the DOM.
// And scrolls also get picked up by rrweb's ScrollObserver
// So we can safely skip the `scrollTop/Left` calls for newly added elements
if (n.scrollLeft) {
attributes.rr_scrollLeft = n.scrollLeft;
}
if (n.scrollTop) {
attributes.rr_scrollTop = n.scrollTop;
}
if ((n as HTMLElement).scrollTop) {
attributes.rr_scrollTop = (n as HTMLElement).scrollTop;
}
// block element
if (needBlock) {
const { width, height } = (n as HTMLElement).getBoundingClientRect();
const { width, height } = n.getBoundingClientRect();
attributes = {
class: attributes.class,
rr_width: `${width}px`,
@@ -620,6 +754,7 @@ function serializeNode(
}
delete attributes.src; // prevent auto loading
}
return {
type: NodeType.Element,
tagName,
@@ -629,69 +764,6 @@ function serializeNode(
needBlock,
rootId,
};
case n.TEXT_NODE:
// The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case.
const parentTagName =
n.parentNode && (n.parentNode as HTMLElement).tagName;
let textContent = (n as Text).textContent;
const isStyle = parentTagName === 'STYLE' ? true : undefined;
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && textContent) {
try {
// try to read style sheet
if (n.nextSibling || n.previousSibling) {
// This is not the only child of the stylesheet.
// We can't read all of the sheet's .cssRules and expect them
// to _only_ include the current rule(s) added by the text node.
// So we'll be conservative and keep textContent as-is.
} else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) {
textContent = stringifyStyleSheet(
(n.parentNode as HTMLStyleElement).sheet!,
);
}
} catch (err) {
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err}`,
n,
);
}
textContent = absoluteToStylesheet(textContent, getHref());
}
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
}
if (
!isStyle &&
!isScript &&
needMaskingText(n, maskTextClass, maskTextSelector) &&
textContent
) {
textContent = maskTextFn
? maskTextFn(textContent)
: textContent.replace(/[\S]/g, '*');
}
return {
type: NodeType.Text,
textContent: textContent || '',
isStyle,
rootId,
};
case n.CDATA_SECTION_NODE:
return {
type: NodeType.CDATA,
textContent: '',
rootId,
};
case n.COMMENT_NODE:
return {
type: NodeType.Comment,
textContent: (n as Comment).textContent || '',
rootId,
};
default:
return false;
}
}
function lowerIfExists(maybeAttr: string | number | boolean): string {
@@ -819,6 +891,7 @@ export function serializeNodeWithId(
node: serializedNodeWithId,
) => unknown;
iframeLoadTimeout?: number;
newlyAddedElement?: boolean;
},
): serializedNodeWithId | null {
const {
@@ -841,6 +914,7 @@ export function serializeNodeWithId(
onIframeLoad,
iframeLoadTimeout = 5000,
keepIframeSrcFn = () => false,
newlyAddedElement = false,
} = options;
let { preserveWhiteSpace = true } = options;
const _serializedNode = serializeNode(n, {
@@ -858,6 +932,7 @@ export function serializeNodeWithId(
inlineImages,
recordCanvas,
keepIframeSrcFn,
newlyAddedElement,
});
if (!_serializedNode) {
// TODO: dev only
@@ -1109,6 +1184,7 @@ function snapshot(
onIframeLoad,
iframeLoadTimeout,
keepIframeSrcFn,
newlyAddedElement: false,
});
}

View File

@@ -13,7 +13,7 @@ export function isElement(n: Node): n is Element {
export function isShadowRoot(n: Node): n is ShadowRoot {
const host: Element | null = (n as ShadowRoot)?.host;
return Boolean(host && host.shadowRoot && host.shadowRoot === n);
return Boolean(host?.shadowRoot === n);
}
export class Mirror implements IMirror<Node> {

View File

@@ -180,3 +180,41 @@ describe('style elements', () => {
});
});
});
describe('scrollTop/scrollLeft', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLDivElement => {
document.write(html);
return document.querySelector('div')!;
};
it('should serialize scroll positions', () => {
const el = render(`<div stylel='overflow: auto; width: 1px; height: 1px;'>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</div>`);
el.scrollTop = 10;
el.scrollLeft = 20;
expect(serializeNode(el)).toMatchObject({
attributes: {
rr_scrollTop: 10,
rr_scrollLeft: 20,
},
});
});
});

View File

@@ -1,5 +1,5 @@
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot';
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, classMatchesRegex, IGNORED_NODE } from './snapshot';
import rebuild, { buildNodeWithSN, addHoverClass, createCache } from './rebuild';
export * from './types';
export * from './utils';
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, createCache, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, };
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, createCache, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, classMatchesRegex, IGNORED_NODE, };

View File

@@ -5,7 +5,8 @@ export declare function absoluteToStylesheet(cssText: string | null, href: strin
export declare function absoluteToDoc(doc: Document, attributeValue: string): string;
export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string): string;
export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null): boolean;
export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean;
export declare function classMatchesRegex(node: Node | null, regex: RegExp, checkAncestors: boolean): boolean;
export declare function needMaskingText(node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean;
export declare function serializeNodeWithId(n: Node, options: {
doc: Document;
mirror: Mirror;
@@ -27,6 +28,7 @@ export declare function serializeNodeWithId(n: Node, options: {
onSerialize?: (n: Node) => unknown;
onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown;
iframeLoadTimeout?: number;
newlyAddedElement?: boolean;
}): serializedNodeWithId | null;
declare function snapshot(n: Document, options?: {
mirror?: Mirror;

View File

@@ -213,6 +213,19 @@ if (process.env.BROWSER_ONLY) {
configs = [];
// browser record + replay, unminified (for profiling and performance testing)
configs.push({
input: './src/index.ts',
plugins: getPlugins(),
output: [
{
name: 'rrweb',
format: 'iife',
file: pkg.unpkg,
},
],
});
for (const c of browserOnlyBaseConfigs) {
configs.push({
input: c.input,

View File

@@ -308,6 +308,7 @@ export default class MutationBuffer {
this.iframeManager.attachIframe(iframe, childSn, this.mirror);
this.shadowDomManager.observeAttachShadow(iframe);
},
newlyAddedElement: true,
});
if (sn) {
adds.push({
@@ -432,7 +433,10 @@ export default class MutationBuffer {
switch (m.type) {
case 'characterData': {
const value = m.target.textContent;
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
if (
!isBlocked(m.target, this.blockClass, false) &&
value !== m.oldValue
) {
this.texts.push({
value:
needMaskingText(
@@ -461,7 +465,10 @@ export default class MutationBuffer {
maskInputFn: this.maskInputFn,
});
}
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
if (
isBlocked(m.target, this.blockClass, false) ||
value === m.oldValue
) {
return;
}
let item: attributeCursor | undefined = this.attributes.find(
@@ -518,6 +525,11 @@ export default class MutationBuffer {
break;
}
case 'childList': {
/**
* Parent is blocked, ignore all child mutations
*/
if (isBlocked(m.target, this.blockClass, true)) return;
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = this.mirror.getId(n);
@@ -525,7 +537,7 @@ export default class MutationBuffer {
? this.mirror.getId(m.target.host)
: this.mirror.getId(m.target);
if (
isBlocked(m.target, this.blockClass) ||
isBlocked(m.target, this.blockClass, false) ||
isIgnored(n, this.mirror) ||
!isSerialized(n, this.mirror)
) {
@@ -571,19 +583,17 @@ export default class MutationBuffer {
}
};
/**
* Make sure you check if `n`'s parent is blocked before calling this function
* */
private genAdds = (n: Node, target?: Node) => {
// parent was blocked, so we can ignore this node
if (target && isBlocked(target, this.blockClass)) {
return;
}
if (this.mirror.getMeta(n)) {
if (this.mirror.hasNode(n)) {
if (isIgnored(n, this.mirror)) {
return;
}
this.movedSet.add(n);
let targetId: number | null = null;
if (target && this.mirror.getMeta(target)) {
if (target && this.mirror.hasNode(target)) {
targetId = this.mirror.getId(target);
}
if (targetId && targetId !== -1) {
@@ -596,8 +606,8 @@ export default class MutationBuffer {
// if this node is blocked `serializeNode` will turn it into a placeholder element
// but we have to remove it's children otherwise they will be added as placeholders too
if (!isBlocked(n, this.blockClass))
(n ).childNodes.forEach((childN) => this.genAdds(childN));
if (!isBlocked(n, this.blockClass, false))
n.childNodes.forEach((childN) => this.genAdds(childN));
};
}
@@ -616,6 +626,15 @@ function isParentRemoved(
removes: removedNodeMutation[],
n: Node,
mirror: Mirror,
): boolean {
if (removes.length === 0) return false;
return _isParentRemoved(removes, n, mirror);
}
function _isParentRemoved(
removes: removedNodeMutation[],
n: Node,
mirror: Mirror,
): boolean {
const { parentNode } = n;
if (!parentNode) {
@@ -625,10 +644,15 @@ function isParentRemoved(
if (removes.some((r) => r.id === parentId)) {
return true;
}
return isParentRemoved(removes, parentNode, mirror);
return _isParentRemoved(removes, parentNode, mirror);
}
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
if (set.size === 0) return false;
return _isAncestorInSet(set, n);
}
function _isAncestorInSet(set: Set<Node>, n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
@@ -636,5 +660,5 @@ function isAncestorInSet(set: Set<Node>, n: Node): boolean {
if (set.has(parentNode)) {
return true;
}
return isAncestorInSet(set, parentNode);
return _isAncestorInSet(set, parentNode);
}

View File

@@ -221,7 +221,7 @@ function initMouseInteractionObserver({
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target, blockClass)) {
if (isBlocked(target, blockClass, true)) {
return;
}
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
@@ -267,7 +267,7 @@ export function initScrollObserver({
>): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
if (!target || isBlocked(target as Node, blockClass, true)) {
return;
}
const id = mirror.getId(target as Node);
@@ -344,7 +344,7 @@ function initInputObserver({
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
isBlocked(target as Node, blockClass)
isBlocked(target as Node, blockClass, true)
) {
return;
}
@@ -549,8 +549,8 @@ function initStyleSheetObserver(
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
unmodifiedFunctions[typeKey] = {
insertRule: (type ).prototype.insertRule,
deleteRule: (type ).prototype.deleteRule,
insertRule: type.prototype.insertRule,
deleteRule: type.prototype.deleteRule,
};
type.prototype.insertRule = function (rule: string, index?: number) {
@@ -653,7 +653,7 @@ function initMediaInteractionObserver({
const handler = (type: MediaInteractions) =>
throttle((event: Event) => {
const target = getEventTarget(event);
if (!target || isBlocked(target as Node, blockClass)) {
if (!target || isBlocked(target as Node, blockClass, true)) {
return;
}
const { currentTime, volume, muted } = target as HTMLMediaElement;

View File

@@ -36,7 +36,7 @@ export default function initCanvas2DMutationObserver(
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked(this.canvas, blockClass)) {
if (!isBlocked(this.canvas, blockClass, true)) {
// Using setTimeout as toDataURL can be heavy
// and we'd rather not block the main thread
setTimeout(() => {

View File

@@ -17,9 +17,8 @@ export default function initCanvasContextObserver(
contextType: string,
...args: Array<unknown>
) {
if (!isBlocked(this, blockClass)) {
if (!('__context' in this))
(this ).__context = contextType;
if (!isBlocked(this, blockClass, true)) {
if (!('__context' in this)) this.__context = contextType;
}
return original.apply(this, [contextType, ...args]);
};

View File

@@ -31,8 +31,7 @@ function patchGLPrototype(
return function (this: typeof prototype, ...args: Array<unknown>) {
const result = original.apply(this, args);
saveWebGLVar(result, win, prototype);
if (!isBlocked(this.canvas , blockClass)) {
const id = mirror.getId(this.canvas );
if (!isBlocked(this.canvas, blockClass, true)) {
const recordArgs = serializeArgs([...args], win, prototype);
const mutation: canvasMutationWithType = {
@@ -41,7 +40,7 @@ function patchGLPrototype(
args: recordArgs,
};
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
cb(this.canvas , mutation);
cb(this.canvas, mutation);
}
return result;

View File

@@ -10,7 +10,7 @@ import type {
textMutation,
} from './types';
import type { IMirror, Mirror } from 'rrweb-snapshot';
import { isShadowRoot, IGNORED_NODE } from 'rrweb-snapshot';
import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot';
import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom';
export function on(
@@ -180,32 +180,34 @@ export function getWindowWidth(): number {
);
}
export function isBlocked(node: Node | null, blockClass: blockClass): boolean {
/**
* Checks if the given element set to be blocked by rrweb
* @param node - node to check
* @param blockClass - class name to check
* @param ignoreParents - whether to search through parent nodes for the block class
* @returns true/false if the node was blocked or not
*/
export function isBlocked(
node: Node | null,
blockClass: blockClass,
checkAncestors: boolean,
): boolean {
if (!node) {
return false;
}
if (node.nodeType === node.ELEMENT_NODE) {
let needBlock = false;
const el: HTMLElement | null =
node.nodeType === node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
if (!el) return false;
if (typeof blockClass === 'string') {
if ((node as HTMLElement).closest !== undefined) {
return (node as HTMLElement).closest('.' + blockClass) !== null;
if (el.classList.contains(blockClass)) return true;
if (checkAncestors && el.closest('.' + blockClass) !== null) return true;
} else {
needBlock = (node as HTMLElement).classList.contains(blockClass);
if (classMatchesRegex(el, blockClass, checkAncestors)) return true;
}
} else {
(node as HTMLElement).classList.forEach((className) => {
if (blockClass.test(className)) {
needBlock = true;
}
});
}
return needBlock || isBlocked(node.parentNode, blockClass);
}
if (node.nodeType === node.TEXT_NODE) {
// check parent node since text node do not have class name
return isBlocked(node.parentNode, blockClass);
}
return isBlocked(node.parentNode, blockClass);
return false;
}
export function isSerialized(n: Node, mirror: Mirror): boolean {

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++) {
const loadPage = async () => {
if ('html' in suite) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, suite.html));
const duration = (await page.evaluate(() => {
} 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);

View File

@@ -10,7 +10,7 @@ export declare function patch(source: {
}, name: string, replacement: (...args: any[]) => any): () => void;
export declare function getWindowHeight(): number;
export declare function getWindowWidth(): number;
export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean;
export declare function isBlocked(node: Node | null, blockClass: blockClass, checkAncestors: boolean): boolean;
export declare function isSerialized(n: Node, mirror: Mirror): boolean;
export declare function isIgnored(n: Node, mirror: Mirror): boolean;
export declare function isAncestorRemoved(target: Node, mirror: Mirror): boolean;

View File

@@ -1560,6 +1560,21 @@
npmlog "^4.1.2"
write-file-atomic "^3.0.3"
"@microsoft/tsdoc-config@0.16.1":
version "0.16.1"
resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.1.tgz#4de11976c1202854c4618f364bf499b4be33e657"
integrity sha512-2RqkwiD4uN6MLnHFljqBlZIXlt/SaUT6cuogU1w2ARw4nKuuppSmR0+s+NC+7kXBQykd9zzu0P4HtBpZT5zBpQ==
dependencies:
"@microsoft/tsdoc" "0.14.1"
ajv "~6.12.6"
jju "~1.4.0"
resolve "~1.19.0"
"@microsoft/tsdoc@0.14.1":
version "0.14.1"
resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd"
integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -2562,7 +2577,7 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0"
indent-string "^4.0.0"
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@~6.12.6:
version "6.12.6"
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@@ -4534,6 +4549,14 @@ eslint-plugin-svelte3@^4.0.0:
resolved "https://registry.yarnpkg.com/eslint-plugin-svelte3/-/eslint-plugin-svelte3-4.0.0.tgz#3d4f3dcaec5761dac8bc697f81de3613b485b4e3"
integrity sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g==
eslint-plugin-tsdoc@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.16.tgz#a3d31fb9c7955faa3c66a43dd43da7635f1c5e0d"
integrity sha512-F/RWMnyDQuGlg82vQEFHQtGyWi7++XJKdYNn0ulIbyMOFqYIjoJOUdE6olORxgwgLkpJxsCJpJbTHgxJ/ggfXw==
dependencies:
"@microsoft/tsdoc" "0.14.1"
"@microsoft/tsdoc-config" "0.16.1"
eslint-scope@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
@@ -5908,6 +5931,13 @@ is-color-stop@^1.0.0:
rgb-regex "^1.0.1"
rgba-regex "^1.0.0"
is-core-module@^2.1.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
dependencies:
has "^1.0.3"
is-core-module@^2.2.0:
version "2.5.0"
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz"
@@ -7137,6 +7167,11 @@ jest@^27.5.1:
import-local "^3.0.2"
jest-cli "^27.5.1"
jju@~1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a"
integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo=
joycon@^3.0.1:
version "3.1.1"
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"