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,
|
tsconfigRootDir: __dirname,
|
||||||
project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'],
|
project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'],
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc'],
|
||||||
rules: {},
|
rules: {
|
||||||
|
'tsdoc/syntax': 'warn',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@typescript-eslint/parser": "^5.25.0",
|
"@typescript-eslint/parser": "^5.25.0",
|
||||||
"concurrently": "^7.1.0",
|
"concurrently": "^7.1.0",
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^8.15.0",
|
||||||
|
"eslint-plugin-tsdoc": "^0.2.16",
|
||||||
"lerna": "^4.0.0",
|
"lerna": "^4.0.0",
|
||||||
"markdownlint": "^0.25.1",
|
"markdownlint": "^0.25.1",
|
||||||
"markdownlint-cli": "^0.31.1",
|
"markdownlint-cli": "^0.31.1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import snapshot, {
|
|||||||
visitSnapshot,
|
visitSnapshot,
|
||||||
cleanupSnapshot,
|
cleanupSnapshot,
|
||||||
needMaskingText,
|
needMaskingText,
|
||||||
|
classMatchesRegex,
|
||||||
IGNORED_NODE,
|
IGNORED_NODE,
|
||||||
} from './snapshot';
|
} from './snapshot';
|
||||||
import rebuild, {
|
import rebuild, {
|
||||||
@@ -25,5 +26,6 @@ export {
|
|||||||
visitSnapshot,
|
visitSnapshot,
|
||||||
cleanupSnapshot,
|
cleanupSnapshot,
|
||||||
needMaskingText,
|
needMaskingText,
|
||||||
|
classMatchesRegex,
|
||||||
IGNORED_NODE,
|
IGNORED_NODE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -268,8 +268,7 @@ export function _isBlockedElement(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// tslint:disable-next-line: prefer-for-of
|
for (let eIndex = element.classList.length; eIndex--; ) {
|
||||||
for (let eIndex = 0; eIndex < element.classList.length; eIndex++) {
|
|
||||||
const className = element.classList[eIndex];
|
const className = element.classList[eIndex];
|
||||||
if (blockClass.test(className)) {
|
if (blockClass.test(className)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -283,44 +282,50 @@ export function _isBlockedElement(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needMaskingText(
|
export function classMatchesRegex(
|
||||||
node: Node | null,
|
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,
|
maskTextClass: string | RegExp,
|
||||||
maskTextSelector: string | null,
|
maskTextSelector: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!node) {
|
const el: HTMLElement | null =
|
||||||
return false;
|
node.nodeType === node.ELEMENT_NODE
|
||||||
|
? (node as HTMLElement)
|
||||||
|
: node.parentElement;
|
||||||
|
if (el === null) return false;
|
||||||
|
|
||||||
|
if (typeof maskTextClass === 'string') {
|
||||||
|
if (el.classList.contains(maskTextClass)) return true;
|
||||||
|
if (el.closest(`.${maskTextClass}`)) return true;
|
||||||
|
} else {
|
||||||
|
if (classMatchesRegex(el, maskTextClass, true)) return true;
|
||||||
}
|
}
|
||||||
if (node.nodeType === node.ELEMENT_NODE) {
|
|
||||||
if (typeof maskTextClass === 'string') {
|
if (maskTextSelector) {
|
||||||
if ((node as HTMLElement).classList.contains(maskTextClass)) {
|
if (el.matches(maskTextSelector)) return true;
|
||||||
return true;
|
if (el.closest(maskTextSelector)) 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 (maskTextSelector) {
|
|
||||||
if ((node as HTMLElement).matches(maskTextSelector)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
|
|
||||||
}
|
}
|
||||||
if (node.nodeType === node.TEXT_NODE) {
|
return false;
|
||||||
// check parent node since text node do not have class name
|
|
||||||
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
|
|
||||||
}
|
|
||||||
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/36155560
|
// https://stackoverflow.com/a/36155560
|
||||||
@@ -389,6 +394,10 @@ function serializeNode(
|
|||||||
inlineImages: boolean;
|
inlineImages: boolean;
|
||||||
recordCanvas: boolean;
|
recordCanvas: boolean;
|
||||||
keepIframeSrcFn: KeepIframeSrcFn;
|
keepIframeSrcFn: KeepIframeSrcFn;
|
||||||
|
/**
|
||||||
|
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
||||||
|
*/
|
||||||
|
newlyAddedElement?: boolean;
|
||||||
},
|
},
|
||||||
): serializedNode | false {
|
): serializedNode | false {
|
||||||
const {
|
const {
|
||||||
@@ -406,20 +415,17 @@ function serializeNode(
|
|||||||
inlineImages,
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
|
newlyAddedElement = false,
|
||||||
} = options;
|
} = options;
|
||||||
// Only record root id when document object is not the base document
|
// Only record root id when document object is not the base document
|
||||||
let rootId: number | undefined;
|
const rootId = getRootId(doc, mirror);
|
||||||
if (mirror.getMeta(doc)) {
|
|
||||||
const docId = mirror.getId(doc);
|
|
||||||
rootId = docId === 1 ? undefined : docId;
|
|
||||||
}
|
|
||||||
switch (n.nodeType) {
|
switch (n.nodeType) {
|
||||||
case n.DOCUMENT_NODE:
|
case n.DOCUMENT_NODE:
|
||||||
if ((n as HTMLDocument).compatMode !== 'CSS1Compat') {
|
if ((n as Document).compatMode !== 'CSS1Compat') {
|
||||||
return {
|
return {
|
||||||
type: NodeType.Document,
|
type: NodeType.Document,
|
||||||
childNodes: [],
|
childNodes: [],
|
||||||
compatMode: (n as HTMLDocument).compatMode, // probably "BackCompat"
|
compatMode: (n as Document).compatMode, // probably "BackCompat"
|
||||||
rootId,
|
rootId,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -438,245 +444,27 @@ function serializeNode(
|
|||||||
rootId,
|
rootId,
|
||||||
};
|
};
|
||||||
case n.ELEMENT_NODE:
|
case n.ELEMENT_NODE:
|
||||||
const needBlock = _isBlockedElement(
|
return serializeElementNode(n as HTMLElement, {
|
||||||
n as HTMLElement,
|
doc,
|
||||||
blockClass,
|
blockClass,
|
||||||
blockSelector,
|
blockSelector,
|
||||||
);
|
inlineStylesheet,
|
||||||
const tagName = getValidTagName(n as HTMLElement);
|
maskInputOptions,
|
||||||
let attributes: attributes = {};
|
maskInputFn,
|
||||||
const len = (n as HTMLElement).attributes.length;
|
dataURLOptions,
|
||||||
for (let i = 0; i < len; i++) {
|
inlineImages,
|
||||||
const attr = (n as HTMLElement).attributes[i];
|
recordCanvas,
|
||||||
attributes[attr.name] = transformAttribute(
|
keepIframeSrcFn,
|
||||||
doc,
|
newlyAddedElement,
|
||||||
tagName,
|
|
||||||
attr.name,
|
|
||||||
attr.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// remote css
|
|
||||||
if (tagName === 'link' && inlineStylesheet) {
|
|
||||||
const stylesheet = Array.from(doc.styleSheets).find((s) => {
|
|
||||||
return s.href === (n as HTMLLinkElement).href;
|
|
||||||
});
|
|
||||||
let cssText: string | null = null;
|
|
||||||
if (stylesheet) {
|
|
||||||
cssText = getCssRulesString(stylesheet );
|
|
||||||
}
|
|
||||||
if (cssText) {
|
|
||||||
delete attributes.rel;
|
|
||||||
delete attributes.href;
|
|
||||||
attributes._cssText = absoluteToStylesheet(
|
|
||||||
cssText,
|
|
||||||
stylesheet!.href!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// dynamic stylesheet
|
|
||||||
if (
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
const cssText = getCssRulesString(
|
|
||||||
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
|
||||||
);
|
|
||||||
if (cssText) {
|
|
||||||
attributes._cssText = absoluteToStylesheet(cssText, getHref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// form fields
|
|
||||||
if (
|
|
||||||
tagName === 'input' ||
|
|
||||||
tagName === 'textarea' ||
|
|
||||||
tagName === 'select'
|
|
||||||
) {
|
|
||||||
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
|
|
||||||
if (
|
|
||||||
attributes.type !== 'radio' &&
|
|
||||||
attributes.type !== 'checkbox' &&
|
|
||||||
attributes.type !== 'submit' &&
|
|
||||||
attributes.type !== 'button' &&
|
|
||||||
value
|
|
||||||
) {
|
|
||||||
attributes.value = maskInputValue({
|
|
||||||
type: attributes.type,
|
|
||||||
tagName,
|
|
||||||
value,
|
|
||||||
maskInputOptions,
|
|
||||||
maskInputFn,
|
|
||||||
});
|
|
||||||
} else if ((n as HTMLInputElement).checked) {
|
|
||||||
attributes.checked = (n as HTMLInputElement).checked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tagName === 'option') {
|
|
||||||
if ((n as HTMLOptionElement).selected && !maskInputOptions['select']) {
|
|
||||||
attributes.selected = true;
|
|
||||||
} else {
|
|
||||||
// ignore the html attribute (which corresponds to DOM (n as HTMLOptionElement).defaultSelected)
|
|
||||||
// if it's already been changed
|
|
||||||
delete attributes.selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// canvas image data
|
|
||||||
if (tagName === 'canvas' && recordCanvas) {
|
|
||||||
if ((n as ICanvas).__context === '2d') {
|
|
||||||
// only record this on 2d canvas
|
|
||||||
if (!is2DCanvasBlank(n as HTMLCanvasElement)) {
|
|
||||||
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(
|
|
||||||
dataURLOptions.type,
|
|
||||||
dataURLOptions.quality,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (!('__context' in n)) {
|
|
||||||
// context is unknown, better not call getContext to trigger it
|
|
||||||
const canvasDataURL = (n as HTMLCanvasElement).toDataURL(
|
|
||||||
dataURLOptions.type,
|
|
||||||
dataURLOptions.quality,
|
|
||||||
);
|
|
||||||
|
|
||||||
// create blank canvas of same dimensions
|
|
||||||
const blankCanvas = document.createElement('canvas');
|
|
||||||
blankCanvas.width = (n as HTMLCanvasElement).width;
|
|
||||||
blankCanvas.height = (n as HTMLCanvasElement).height;
|
|
||||||
const blankCanvasDataURL = blankCanvas.toDataURL(
|
|
||||||
dataURLOptions.type,
|
|
||||||
dataURLOptions.quality,
|
|
||||||
);
|
|
||||||
|
|
||||||
// no need to save dataURL if it's the same as blank canvas
|
|
||||||
if (canvasDataURL !== blankCanvasDataURL) {
|
|
||||||
attributes.rr_dataURL = canvasDataURL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// save image offline
|
|
||||||
if (tagName === 'img' && inlineImages) {
|
|
||||||
if (!canvasService) {
|
|
||||||
canvasService = doc.createElement('canvas');
|
|
||||||
canvasCtx = canvasService.getContext('2d');
|
|
||||||
}
|
|
||||||
const image = n as HTMLImageElement;
|
|
||||||
const oldValue = image.crossOrigin;
|
|
||||||
image.crossOrigin = 'anonymous';
|
|
||||||
const recordInlineImage = () => {
|
|
||||||
try {
|
|
||||||
canvasService!.width = image.naturalWidth;
|
|
||||||
canvasService!.height = image.naturalHeight;
|
|
||||||
canvasCtx!.drawImage(image, 0, 0);
|
|
||||||
attributes.rr_dataURL = canvasService!.toDataURL(
|
|
||||||
dataURLOptions.type,
|
|
||||||
dataURLOptions.quality,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`Cannot inline img src=${image.currentSrc}! Error: ${err}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
oldValue
|
|
||||||
? (attributes.crossOrigin = oldValue)
|
|
||||||
: image.removeAttribute('crossorigin');
|
|
||||||
};
|
|
||||||
// The image content may not have finished loading yet.
|
|
||||||
if (image.complete && image.naturalWidth !== 0) recordInlineImage();
|
|
||||||
else image.onload = recordInlineImage;
|
|
||||||
}
|
|
||||||
// media elements
|
|
||||||
if (tagName === 'audio' || tagName === 'video') {
|
|
||||||
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
|
||||||
? 'paused'
|
|
||||||
: 'played';
|
|
||||||
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
|
|
||||||
}
|
|
||||||
// scroll
|
|
||||||
if ((n as HTMLElement).scrollLeft) {
|
|
||||||
attributes.rr_scrollLeft = (n as HTMLElement).scrollLeft;
|
|
||||||
}
|
|
||||||
if ((n as HTMLElement).scrollTop) {
|
|
||||||
attributes.rr_scrollTop = (n as HTMLElement).scrollTop;
|
|
||||||
}
|
|
||||||
// block element
|
|
||||||
if (needBlock) {
|
|
||||||
const { width, height } = (n as HTMLElement).getBoundingClientRect();
|
|
||||||
attributes = {
|
|
||||||
class: attributes.class,
|
|
||||||
rr_width: `${width}px`,
|
|
||||||
rr_height: `${height}px`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// iframe
|
|
||||||
if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) {
|
|
||||||
if (!(n as HTMLIFrameElement).contentDocument) {
|
|
||||||
// we can't record it directly as we can't see into it
|
|
||||||
// preserve the src attribute so a decision can be taken at replay time
|
|
||||||
attributes.rr_src = attributes.src;
|
|
||||||
}
|
|
||||||
delete attributes.src; // prevent auto loading
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: NodeType.Element,
|
|
||||||
tagName,
|
|
||||||
attributes,
|
|
||||||
childNodes: [],
|
|
||||||
isSVG: isSVGElement(n as Element) || undefined,
|
|
||||||
needBlock,
|
|
||||||
rootId,
|
rootId,
|
||||||
};
|
});
|
||||||
case n.TEXT_NODE:
|
case n.TEXT_NODE:
|
||||||
// The parent node may not be a html element which has a tagName attribute.
|
return serializeTextNode(n as Text, {
|
||||||
// So just let it be undefined which is ok in this use case.
|
maskTextClass,
|
||||||
const parentTagName =
|
maskTextSelector,
|
||||||
n.parentNode && (n.parentNode as HTMLElement).tagName;
|
maskTextFn,
|
||||||
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,
|
rootId,
|
||||||
};
|
});
|
||||||
case n.CDATA_SECTION_NODE:
|
case n.CDATA_SECTION_NODE:
|
||||||
return {
|
return {
|
||||||
type: NodeType.CDATA,
|
type: NodeType.CDATA,
|
||||||
@@ -694,6 +482,290 @@ function serializeNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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.attributes.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const attr = n.attributes[i];
|
||||||
|
attributes[attr.name] = transformAttribute(
|
||||||
|
doc,
|
||||||
|
tagName,
|
||||||
|
attr.name,
|
||||||
|
attr.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// remote css
|
||||||
|
if (tagName === 'link' && inlineStylesheet) {
|
||||||
|
const stylesheet = Array.from(doc.styleSheets).find((s) => {
|
||||||
|
return s.href === (n as HTMLLinkElement).href;
|
||||||
|
});
|
||||||
|
let cssText: string | null = null;
|
||||||
|
if (stylesheet) {
|
||||||
|
cssText = getCssRulesString(stylesheet);
|
||||||
|
}
|
||||||
|
if (cssText) {
|
||||||
|
delete attributes.rel;
|
||||||
|
delete attributes.href;
|
||||||
|
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// dynamic stylesheet
|
||||||
|
if (
|
||||||
|
tagName === 'style' &&
|
||||||
|
(n as HTMLStyleElement).sheet &&
|
||||||
|
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
|
||||||
|
!(n.innerText || n.textContent || '').trim().length
|
||||||
|
) {
|
||||||
|
const cssText = getCssRulesString(
|
||||||
|
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
||||||
|
);
|
||||||
|
if (cssText) {
|
||||||
|
attributes._cssText = absoluteToStylesheet(cssText, getHref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// form fields
|
||||||
|
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||||
|
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
|
||||||
|
if (
|
||||||
|
attributes.type !== 'radio' &&
|
||||||
|
attributes.type !== 'checkbox' &&
|
||||||
|
attributes.type !== 'submit' &&
|
||||||
|
attributes.type !== 'button' &&
|
||||||
|
value
|
||||||
|
) {
|
||||||
|
attributes.value = maskInputValue({
|
||||||
|
type: attributes.type,
|
||||||
|
tagName,
|
||||||
|
value,
|
||||||
|
maskInputOptions,
|
||||||
|
maskInputFn,
|
||||||
|
});
|
||||||
|
} else if ((n as HTMLInputElement).checked) {
|
||||||
|
attributes.checked = (n as HTMLInputElement).checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tagName === 'option') {
|
||||||
|
if ((n as HTMLOptionElement).selected && !maskInputOptions['select']) {
|
||||||
|
attributes.selected = true;
|
||||||
|
} else {
|
||||||
|
// ignore the html attribute (which corresponds to DOM (n as HTMLOptionElement).defaultSelected)
|
||||||
|
// if it's already been changed
|
||||||
|
delete attributes.selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// canvas image data
|
||||||
|
if (tagName === 'canvas' && recordCanvas) {
|
||||||
|
if ((n as ICanvas).__context === '2d') {
|
||||||
|
// only record this on 2d canvas
|
||||||
|
if (!is2DCanvasBlank(n as HTMLCanvasElement)) {
|
||||||
|
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(
|
||||||
|
dataURLOptions.type,
|
||||||
|
dataURLOptions.quality,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!('__context' in n)) {
|
||||||
|
// context is unknown, better not call getContext to trigger it
|
||||||
|
const canvasDataURL = (n as HTMLCanvasElement).toDataURL(
|
||||||
|
dataURLOptions.type,
|
||||||
|
dataURLOptions.quality,
|
||||||
|
);
|
||||||
|
|
||||||
|
// create blank canvas of same dimensions
|
||||||
|
const blankCanvas = document.createElement('canvas');
|
||||||
|
blankCanvas.width = (n as HTMLCanvasElement).width;
|
||||||
|
blankCanvas.height = (n as HTMLCanvasElement).height;
|
||||||
|
const blankCanvasDataURL = blankCanvas.toDataURL(
|
||||||
|
dataURLOptions.type,
|
||||||
|
dataURLOptions.quality,
|
||||||
|
);
|
||||||
|
|
||||||
|
// no need to save dataURL if it's the same as blank canvas
|
||||||
|
if (canvasDataURL !== blankCanvasDataURL) {
|
||||||
|
attributes.rr_dataURL = canvasDataURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// save image offline
|
||||||
|
if (tagName === 'img' && inlineImages) {
|
||||||
|
if (!canvasService) {
|
||||||
|
canvasService = doc.createElement('canvas');
|
||||||
|
canvasCtx = canvasService.getContext('2d');
|
||||||
|
}
|
||||||
|
const image = n as HTMLImageElement;
|
||||||
|
const oldValue = image.crossOrigin;
|
||||||
|
image.crossOrigin = 'anonymous';
|
||||||
|
const recordInlineImage = () => {
|
||||||
|
try {
|
||||||
|
canvasService!.width = image.naturalWidth;
|
||||||
|
canvasService!.height = image.naturalHeight;
|
||||||
|
canvasCtx!.drawImage(image, 0, 0);
|
||||||
|
attributes.rr_dataURL = canvasService!.toDataURL(
|
||||||
|
dataURLOptions.type,
|
||||||
|
dataURLOptions.quality,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot inline img src=${image.currentSrc}! Error: ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
oldValue
|
||||||
|
? (attributes.crossOrigin = oldValue)
|
||||||
|
: image.removeAttribute('crossorigin');
|
||||||
|
};
|
||||||
|
// The image content may not have finished loading yet.
|
||||||
|
if (image.complete && image.naturalWidth !== 0) recordInlineImage();
|
||||||
|
else image.onload = recordInlineImage;
|
||||||
|
}
|
||||||
|
// media elements
|
||||||
|
if (tagName === 'audio' || tagName === 'video') {
|
||||||
|
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
||||||
|
? 'paused'
|
||||||
|
: 'played';
|
||||||
|
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// block element
|
||||||
|
if (needBlock) {
|
||||||
|
const { width, height } = n.getBoundingClientRect();
|
||||||
|
attributes = {
|
||||||
|
class: attributes.class,
|
||||||
|
rr_width: `${width}px`,
|
||||||
|
rr_height: `${height}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// iframe
|
||||||
|
if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) {
|
||||||
|
if (!(n as HTMLIFrameElement).contentDocument) {
|
||||||
|
// we can't record it directly as we can't see into it
|
||||||
|
// preserve the src attribute so a decision can be taken at replay time
|
||||||
|
attributes.rr_src = attributes.src;
|
||||||
|
}
|
||||||
|
delete attributes.src; // prevent auto loading
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: NodeType.Element,
|
||||||
|
tagName,
|
||||||
|
attributes,
|
||||||
|
childNodes: [],
|
||||||
|
isSVG: isSVGElement(n as Element) || undefined,
|
||||||
|
needBlock,
|
||||||
|
rootId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function lowerIfExists(maybeAttr: string | number | boolean): string {
|
function lowerIfExists(maybeAttr: string | number | boolean): string {
|
||||||
if (maybeAttr === undefined) {
|
if (maybeAttr === undefined) {
|
||||||
return '';
|
return '';
|
||||||
@@ -819,6 +891,7 @@ export function serializeNodeWithId(
|
|||||||
node: serializedNodeWithId,
|
node: serializedNodeWithId,
|
||||||
) => unknown;
|
) => unknown;
|
||||||
iframeLoadTimeout?: number;
|
iframeLoadTimeout?: number;
|
||||||
|
newlyAddedElement?: boolean;
|
||||||
},
|
},
|
||||||
): serializedNodeWithId | null {
|
): serializedNodeWithId | null {
|
||||||
const {
|
const {
|
||||||
@@ -841,6 +914,7 @@ export function serializeNodeWithId(
|
|||||||
onIframeLoad,
|
onIframeLoad,
|
||||||
iframeLoadTimeout = 5000,
|
iframeLoadTimeout = 5000,
|
||||||
keepIframeSrcFn = () => false,
|
keepIframeSrcFn = () => false,
|
||||||
|
newlyAddedElement = false,
|
||||||
} = options;
|
} = options;
|
||||||
let { preserveWhiteSpace = true } = options;
|
let { preserveWhiteSpace = true } = options;
|
||||||
const _serializedNode = serializeNode(n, {
|
const _serializedNode = serializeNode(n, {
|
||||||
@@ -858,6 +932,7 @@ export function serializeNodeWithId(
|
|||||||
inlineImages,
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
|
newlyAddedElement,
|
||||||
});
|
});
|
||||||
if (!_serializedNode) {
|
if (!_serializedNode) {
|
||||||
// TODO: dev only
|
// TODO: dev only
|
||||||
@@ -1109,6 +1184,7 @@ function snapshot(
|
|||||||
onIframeLoad,
|
onIframeLoad,
|
||||||
iframeLoadTimeout,
|
iframeLoadTimeout,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
|
newlyAddedElement: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function isElement(n: Node): n is Element {
|
|||||||
|
|
||||||
export function isShadowRoot(n: Node): n is ShadowRoot {
|
export function isShadowRoot(n: Node): n is ShadowRoot {
|
||||||
const host: Element | null = (n as ShadowRoot)?.host;
|
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> {
|
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';
|
import rebuild, { buildNodeWithSN, addHoverClass, createCache } from './rebuild';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
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 absoluteToDoc(doc: Document, attributeValue: string): string;
|
||||||
export declare function transformAttribute(doc: Document, tagName: string, name: string, value: 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 _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: {
|
export declare function serializeNodeWithId(n: Node, options: {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
mirror: Mirror;
|
mirror: Mirror;
|
||||||
@@ -27,6 +28,7 @@ export declare function serializeNodeWithId(n: Node, options: {
|
|||||||
onSerialize?: (n: Node) => unknown;
|
onSerialize?: (n: Node) => unknown;
|
||||||
onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown;
|
onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown;
|
||||||
iframeLoadTimeout?: number;
|
iframeLoadTimeout?: number;
|
||||||
|
newlyAddedElement?: boolean;
|
||||||
}): serializedNodeWithId | null;
|
}): serializedNodeWithId | null;
|
||||||
declare function snapshot(n: Document, options?: {
|
declare function snapshot(n: Document, options?: {
|
||||||
mirror?: Mirror;
|
mirror?: Mirror;
|
||||||
|
|||||||
@@ -213,6 +213,19 @@ if (process.env.BROWSER_ONLY) {
|
|||||||
|
|
||||||
configs = [];
|
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) {
|
for (const c of browserOnlyBaseConfigs) {
|
||||||
configs.push({
|
configs.push({
|
||||||
input: c.input,
|
input: c.input,
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ export default class MutationBuffer {
|
|||||||
this.iframeManager.attachIframe(iframe, childSn, this.mirror);
|
this.iframeManager.attachIframe(iframe, childSn, this.mirror);
|
||||||
this.shadowDomManager.observeAttachShadow(iframe);
|
this.shadowDomManager.observeAttachShadow(iframe);
|
||||||
},
|
},
|
||||||
|
newlyAddedElement: true,
|
||||||
});
|
});
|
||||||
if (sn) {
|
if (sn) {
|
||||||
adds.push({
|
adds.push({
|
||||||
@@ -432,7 +433,10 @@ export default class MutationBuffer {
|
|||||||
switch (m.type) {
|
switch (m.type) {
|
||||||
case 'characterData': {
|
case 'characterData': {
|
||||||
const value = m.target.textContent;
|
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({
|
this.texts.push({
|
||||||
value:
|
value:
|
||||||
needMaskingText(
|
needMaskingText(
|
||||||
@@ -461,7 +465,10 @@ export default class MutationBuffer {
|
|||||||
maskInputFn: this.maskInputFn,
|
maskInputFn: this.maskInputFn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
|
if (
|
||||||
|
isBlocked(m.target, this.blockClass, false) ||
|
||||||
|
value === m.oldValue
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let item: attributeCursor | undefined = this.attributes.find(
|
let item: attributeCursor | undefined = this.attributes.find(
|
||||||
@@ -518,6 +525,11 @@ export default class MutationBuffer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'childList': {
|
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.addedNodes.forEach((n) => this.genAdds(n, m.target));
|
||||||
m.removedNodes.forEach((n) => {
|
m.removedNodes.forEach((n) => {
|
||||||
const nodeId = this.mirror.getId(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.host)
|
||||||
: this.mirror.getId(m.target);
|
: this.mirror.getId(m.target);
|
||||||
if (
|
if (
|
||||||
isBlocked(m.target, this.blockClass) ||
|
isBlocked(m.target, this.blockClass, false) ||
|
||||||
isIgnored(n, this.mirror) ||
|
isIgnored(n, this.mirror) ||
|
||||||
!isSerialized(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) => {
|
private genAdds = (n: Node, target?: Node) => {
|
||||||
// parent was blocked, so we can ignore this node
|
if (this.mirror.hasNode(n)) {
|
||||||
if (target && isBlocked(target, this.blockClass)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mirror.getMeta(n)) {
|
|
||||||
if (isIgnored(n, this.mirror)) {
|
if (isIgnored(n, this.mirror)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.movedSet.add(n);
|
this.movedSet.add(n);
|
||||||
let targetId: number | null = null;
|
let targetId: number | null = null;
|
||||||
if (target && this.mirror.getMeta(target)) {
|
if (target && this.mirror.hasNode(target)) {
|
||||||
targetId = this.mirror.getId(target);
|
targetId = this.mirror.getId(target);
|
||||||
}
|
}
|
||||||
if (targetId && targetId !== -1) {
|
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
|
// 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
|
// but we have to remove it's children otherwise they will be added as placeholders too
|
||||||
if (!isBlocked(n, this.blockClass))
|
if (!isBlocked(n, this.blockClass, false))
|
||||||
(n ).childNodes.forEach((childN) => this.genAdds(childN));
|
n.childNodes.forEach((childN) => this.genAdds(childN));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +626,15 @@ function isParentRemoved(
|
|||||||
removes: removedNodeMutation[],
|
removes: removedNodeMutation[],
|
||||||
n: Node,
|
n: Node,
|
||||||
mirror: Mirror,
|
mirror: Mirror,
|
||||||
|
): boolean {
|
||||||
|
if (removes.length === 0) return false;
|
||||||
|
return _isParentRemoved(removes, n, mirror);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isParentRemoved(
|
||||||
|
removes: removedNodeMutation[],
|
||||||
|
n: Node,
|
||||||
|
mirror: Mirror,
|
||||||
): boolean {
|
): boolean {
|
||||||
const { parentNode } = n;
|
const { parentNode } = n;
|
||||||
if (!parentNode) {
|
if (!parentNode) {
|
||||||
@@ -625,10 +644,15 @@ function isParentRemoved(
|
|||||||
if (removes.some((r) => r.id === parentId)) {
|
if (removes.some((r) => r.id === parentId)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isParentRemoved(removes, parentNode, mirror);
|
return _isParentRemoved(removes, parentNode, mirror);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
|
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;
|
const { parentNode } = n;
|
||||||
if (!parentNode) {
|
if (!parentNode) {
|
||||||
return false;
|
return false;
|
||||||
@@ -636,5 +660,5 @@ function isAncestorInSet(set: Set<Node>, n: Node): boolean {
|
|||||||
if (set.has(parentNode)) {
|
if (set.has(parentNode)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isAncestorInSet(set, parentNode);
|
return _isAncestorInSet(set, parentNode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function initMouseInteractionObserver({
|
|||||||
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
|
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
|
||||||
return (event: MouseEvent | TouchEvent) => {
|
return (event: MouseEvent | TouchEvent) => {
|
||||||
const target = getEventTarget(event) as Node;
|
const target = getEventTarget(event) as Node;
|
||||||
if (isBlocked(target, blockClass)) {
|
if (isBlocked(target, blockClass, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
|
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
|
||||||
@@ -267,7 +267,7 @@ export function initScrollObserver({
|
|||||||
>): listenerHandler {
|
>): listenerHandler {
|
||||||
const updatePosition = throttle<UIEvent>((evt) => {
|
const updatePosition = throttle<UIEvent>((evt) => {
|
||||||
const target = getEventTarget(evt);
|
const target = getEventTarget(evt);
|
||||||
if (!target || isBlocked(target as Node, blockClass)) {
|
if (!target || isBlocked(target as Node, blockClass, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = mirror.getId(target as Node);
|
const id = mirror.getId(target as Node);
|
||||||
@@ -344,7 +344,7 @@ function initInputObserver({
|
|||||||
!target ||
|
!target ||
|
||||||
!(target as Element).tagName ||
|
!(target as Element).tagName ||
|
||||||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
|
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
|
||||||
isBlocked(target as Node, blockClass)
|
isBlocked(target as Node, blockClass, true)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -549,8 +549,8 @@ function initStyleSheetObserver(
|
|||||||
|
|
||||||
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
|
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
|
||||||
unmodifiedFunctions[typeKey] = {
|
unmodifiedFunctions[typeKey] = {
|
||||||
insertRule: (type ).prototype.insertRule,
|
insertRule: type.prototype.insertRule,
|
||||||
deleteRule: (type ).prototype.deleteRule,
|
deleteRule: type.prototype.deleteRule,
|
||||||
};
|
};
|
||||||
|
|
||||||
type.prototype.insertRule = function (rule: string, index?: number) {
|
type.prototype.insertRule = function (rule: string, index?: number) {
|
||||||
@@ -653,7 +653,7 @@ function initMediaInteractionObserver({
|
|||||||
const handler = (type: MediaInteractions) =>
|
const handler = (type: MediaInteractions) =>
|
||||||
throttle((event: Event) => {
|
throttle((event: Event) => {
|
||||||
const target = getEventTarget(event);
|
const target = getEventTarget(event);
|
||||||
if (!target || isBlocked(target as Node, blockClass)) {
|
if (!target || isBlocked(target as Node, blockClass, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { currentTime, volume, muted } = target as HTMLMediaElement;
|
const { currentTime, volume, muted } = target as HTMLMediaElement;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function initCanvas2DMutationObserver(
|
|||||||
this: CanvasRenderingContext2D,
|
this: CanvasRenderingContext2D,
|
||||||
...args: Array<unknown>
|
...args: Array<unknown>
|
||||||
) {
|
) {
|
||||||
if (!isBlocked(this.canvas, blockClass)) {
|
if (!isBlocked(this.canvas, blockClass, true)) {
|
||||||
// Using setTimeout as toDataURL can be heavy
|
// Using setTimeout as toDataURL can be heavy
|
||||||
// and we'd rather not block the main thread
|
// and we'd rather not block the main thread
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ export default function initCanvasContextObserver(
|
|||||||
contextType: string,
|
contextType: string,
|
||||||
...args: Array<unknown>
|
...args: Array<unknown>
|
||||||
) {
|
) {
|
||||||
if (!isBlocked(this, blockClass)) {
|
if (!isBlocked(this, blockClass, true)) {
|
||||||
if (!('__context' in this))
|
if (!('__context' in this)) this.__context = contextType;
|
||||||
(this ).__context = contextType;
|
|
||||||
}
|
}
|
||||||
return original.apply(this, [contextType, ...args]);
|
return original.apply(this, [contextType, ...args]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ function patchGLPrototype(
|
|||||||
return function (this: typeof prototype, ...args: Array<unknown>) {
|
return function (this: typeof prototype, ...args: Array<unknown>) {
|
||||||
const result = original.apply(this, args);
|
const result = original.apply(this, args);
|
||||||
saveWebGLVar(result, win, prototype);
|
saveWebGLVar(result, win, prototype);
|
||||||
if (!isBlocked(this.canvas , blockClass)) {
|
if (!isBlocked(this.canvas, blockClass, true)) {
|
||||||
const id = mirror.getId(this.canvas );
|
|
||||||
|
|
||||||
const recordArgs = serializeArgs([...args], win, prototype);
|
const recordArgs = serializeArgs([...args], win, prototype);
|
||||||
const mutation: canvasMutationWithType = {
|
const mutation: canvasMutationWithType = {
|
||||||
@@ -41,7 +40,7 @@ function patchGLPrototype(
|
|||||||
args: recordArgs,
|
args: recordArgs,
|
||||||
};
|
};
|
||||||
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
|
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
|
||||||
cb(this.canvas , mutation);
|
cb(this.canvas, mutation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
textMutation,
|
textMutation,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { IMirror, Mirror } from 'rrweb-snapshot';
|
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';
|
import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom';
|
||||||
|
|
||||||
export function on(
|
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) {
|
if (!node) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (node.nodeType === node.ELEMENT_NODE) {
|
const el: HTMLElement | null =
|
||||||
let needBlock = false;
|
node.nodeType === node.ELEMENT_NODE
|
||||||
if (typeof blockClass === 'string') {
|
? (node as HTMLElement)
|
||||||
if ((node as HTMLElement).closest !== undefined) {
|
: node.parentElement;
|
||||||
return (node as HTMLElement).closest('.' + blockClass) !== null;
|
if (!el) return false;
|
||||||
} else {
|
|
||||||
needBlock = (node as HTMLElement).classList.contains(blockClass);
|
if (typeof blockClass === 'string') {
|
||||||
}
|
if (el.classList.contains(blockClass)) return true;
|
||||||
} else {
|
if (checkAncestors && el.closest('.' + blockClass) !== null) return true;
|
||||||
(node as HTMLElement).classList.forEach((className) => {
|
} else {
|
||||||
if (blockClass.test(className)) {
|
if (classMatchesRegex(el, blockClass, checkAncestors)) return true;
|
||||||
needBlock = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return needBlock || isBlocked(node.parentNode, blockClass);
|
|
||||||
}
|
}
|
||||||
if (node.nodeType === node.TEXT_NODE) {
|
return false;
|
||||||
// check parent node since text node do not have class name
|
|
||||||
return isBlocked(node.parentNode, blockClass);
|
|
||||||
}
|
|
||||||
return isBlocked(node.parentNode, blockClass);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSerialized(n: Node, mirror: Mirror): boolean {
|
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`] = `
|
exports[`record without CSSGroupingRule support captures nested stylesheet rules 1`] = `
|
||||||
"[
|
"[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
// tslint:disable:no-console no-any
|
// tslint:disable:no-console no-any
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import type { Page } from 'puppeteer';
|
||||||
import type { eventWithTime, recordOptions } from '../../src/types';
|
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 {
|
function avg(v: number[]): number {
|
||||||
return v.reduce((prev, cur) => prev + cur, 0) / v.length;
|
return v.reduce((prev, cur) => prev + cur, 0) / v.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('benchmark: mutation observer', () => {
|
describe('benchmark: mutation observer', () => {
|
||||||
let code: ISuite['code'];
|
jest.setTimeout(240000);
|
||||||
let page: ISuite['page'];
|
let page: ISuite['page'];
|
||||||
let browser: ISuite['browser'];
|
let browser: ISuite['browser'];
|
||||||
let server: ISuite['server'];
|
let server: ISuite['server'];
|
||||||
@@ -20,9 +48,6 @@ describe('benchmark: mutation observer', () => {
|
|||||||
dumpio: true,
|
dumpio: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
|
|
||||||
code = fs.readFileSync(bundlePath, 'utf8');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -36,30 +61,19 @@ describe('benchmark: mutation observer', () => {
|
|||||||
|
|
||||||
const getHtml = (fileName: string): string => {
|
const getHtml = (fileName: string): string => {
|
||||||
const filePath = path.resolve(__dirname, `../html/${fileName}`);
|
const filePath = path.resolve(__dirname, `../html/${fileName}`);
|
||||||
const html = fs.readFileSync(filePath, 'utf8');
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
return replaceLast(
|
|
||||||
html,
|
|
||||||
'</body>',
|
|
||||||
`
|
|
||||||
<script>
|
|
||||||
${code}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const suites: {
|
const addRecordingScript = async (page: Page) => {
|
||||||
title: string;
|
// const scriptUrl = `${getServerURL(server)}/rrweb-1.1.3.js`;
|
||||||
html: string;
|
const scriptUrl = `${getServerURL(server)}/rrweb.js`;
|
||||||
times?: number; // default to 5
|
await page.evaluate((url) => {
|
||||||
}[] = [
|
const scriptEl = document.createElement('script');
|
||||||
{
|
scriptEl.src = url;
|
||||||
title: 'create 1000x10 DOM nodes',
|
document.head.append(scriptEl);
|
||||||
html: 'benchmark-dom-mutation.html',
|
}, scriptUrl);
|
||||||
times: 10,
|
await page.waitForFunction('window.rrweb');
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
|
||||||
for (const suite of suites) {
|
for (const suite of suites) {
|
||||||
it(suite.title, async () => {
|
it(suite.title, async () => {
|
||||||
@@ -68,12 +82,19 @@ describe('benchmark: mutation observer', () => {
|
|||||||
console.log(`${message.type().toUpperCase()} ${message.text()}`),
|
console.log(`${message.type().toUpperCase()} ${message.text()}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
const times = suite.times ?? 5;
|
const loadPage = async () => {
|
||||||
const durations: number[] = [];
|
if ('html' in suite) {
|
||||||
for (let i = 0; i < times; i++) {
|
await page.goto('about:blank');
|
||||||
await page.goto('about:blank');
|
await page.setContent(getHtml.call(this, suite.html));
|
||||||
await page.setContent(getHtml.call(this, suite.html));
|
} else {
|
||||||
const duration = (await page.evaluate(() => {
|
await page.goto(suite.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
await addRecordingScript(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDuration = async (): Promise<number> => {
|
||||||
|
return (await page.evaluate((triggerWorkloadScript) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let start = 0;
|
let start = 0;
|
||||||
let lastEvent: eventWithTime | null;
|
let lastEvent: eventWithTime | null;
|
||||||
@@ -94,14 +115,55 @@ describe('benchmark: mutation observer', () => {
|
|||||||
const record = (window as any).rrweb.record;
|
const record = (window as any).rrweb.record;
|
||||||
record(options);
|
record(options);
|
||||||
|
|
||||||
(window as any).workload();
|
|
||||||
|
|
||||||
start = Date.now();
|
start = Date.now();
|
||||||
setTimeout(() => {
|
eval(triggerWorkloadScript);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
record.addCustomEvent('FTAG', {});
|
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);
|
durations.push(duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +174,7 @@ describe('benchmark: mutation observer', () => {
|
|||||||
durations: durations.join(', '),
|
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);
|
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 () => {
|
it('can add custom event', async () => {
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;
|
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const startServer = (defaultPort: number = 3030) =>
|
|||||||
const sanitizePath = path
|
const sanitizePath = path
|
||||||
.normalize(parsedUrl.pathname!)
|
.normalize(parsedUrl.pathname!)
|
||||||
.replace(/^(\.\.[\/\\])+/, '');
|
.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 {
|
try {
|
||||||
const data = fs.readFileSync(pathname);
|
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;
|
}, name: string, replacement: (...args: any[]) => any): () => void;
|
||||||
export declare function getWindowHeight(): number;
|
export declare function getWindowHeight(): number;
|
||||||
export declare function getWindowWidth(): 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 isSerialized(n: Node, mirror: Mirror): boolean;
|
||||||
export declare function isIgnored(n: Node, mirror: Mirror): boolean;
|
export declare function isIgnored(n: Node, mirror: Mirror): boolean;
|
||||||
export declare function isAncestorRemoved(target: 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"
|
npmlog "^4.1.2"
|
||||||
write-file-atomic "^3.0.3"
|
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":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
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"
|
clean-stack "^2.0.0"
|
||||||
indent-string "^4.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"
|
version "6.12.6"
|
||||||
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
|
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
|
||||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
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"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-svelte3/-/eslint-plugin-svelte3-4.0.0.tgz#3d4f3dcaec5761dac8bc697f81de3613b485b4e3"
|
||||||
integrity sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g==
|
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:
|
eslint-scope@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
|
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"
|
rgb-regex "^1.0.1"
|
||||||
rgba-regex "^1.0.0"
|
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:
|
is-core-module@^2.2.0:
|
||||||
version "2.5.0"
|
version "2.5.0"
|
||||||
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz"
|
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"
|
import-local "^3.0.2"
|
||||||
jest-cli "^27.5.1"
|
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:
|
joycon@^3.0.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user