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:
@@ -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;
|
||||
const el: HTMLElement | null =
|
||||
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 ((node as HTMLElement).classList.contains(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 (maskTextSelector) {
|
||||
if ((node as HTMLElement).matches(maskTextSelector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
|
||||
|
||||
if (maskTextSelector) {
|
||||
if (el.matches(maskTextSelector)) return true;
|
||||
if (el.closest(maskTextSelector)) return true;
|
||||
}
|
||||
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,245 +444,27 @@ function serializeNode(
|
||||
rootId,
|
||||
};
|
||||
case n.ELEMENT_NODE:
|
||||
const needBlock = _isBlockedElement(
|
||||
n as HTMLElement,
|
||||
return serializeElementNode(n as HTMLElement, {
|
||||
doc,
|
||||
blockClass,
|
||||
blockSelector,
|
||||
);
|
||||
const tagName = getValidTagName(n as HTMLElement);
|
||||
let attributes: attributes = {};
|
||||
const len = (n as HTMLElement).attributes.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const attr = (n as HTMLElement).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 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,
|
||||
inlineStylesheet,
|
||||
maskInputOptions,
|
||||
maskInputFn,
|
||||
dataURLOptions,
|
||||
inlineImages,
|
||||
recordCanvas,
|
||||
keepIframeSrcFn,
|
||||
newlyAddedElement,
|
||||
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,
|
||||
return serializeTextNode(n as Text, {
|
||||
maskTextClass,
|
||||
maskTextSelector,
|
||||
maskTextFn,
|
||||
rootId,
|
||||
};
|
||||
});
|
||||
case n.CDATA_SECTION_NODE:
|
||||
return {
|
||||
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 {
|
||||
if (maybeAttr === undefined) {
|
||||
return '';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user