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> {
|
||||
|
||||
Reference in New Issue
Block a user