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:
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -467,10 +606,7 @@ function serializeNode(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
4
packages/rrweb-snapshot/typings/index.d.ts
vendored
4
packages/rrweb-snapshot/typings/index.d.ts
vendored
@@ -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, };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`] = `
|
||||
"[
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
2
packages/rrweb/typings/utils.d.ts
vendored
2
packages/rrweb/typings/utils.d.ts
vendored
@@ -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;
|
||||
|
||||
37
yarn.lock
37
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user