diff --git a/.eslintrc.js b/.eslintrc.js index 994f4229b1..2f332b6bbd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,8 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'], }, - plugins: ['@typescript-eslint'], - rules: {}, + plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc'], + rules: { + 'tsdoc/syntax': 'warn', + }, }; diff --git a/package.json b/package.json index eeb6276710..61c9b5227d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@typescript-eslint/parser": "^5.25.0", "concurrently": "^7.1.0", "eslint": "^8.15.0", + "eslint-plugin-tsdoc": "^0.2.16", "lerna": "^4.0.0", "markdownlint": "^0.25.1", "markdownlint-cli": "^0.31.1", diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 5bb5eea687..82dd6a42c3 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -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, }; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index d6247e44a1..1694d3f222 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -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, - maskTextClass: string | RegExp, - maskTextSelector: string | null, + regex: RegExp, + checkAncestors: boolean, ): boolean { - if (!node) { - return false; + if (!node) return false; + if (node.nodeType !== node.ELEMENT_NODE) { + if (!checkAncestors) return false; + return classMatchesRegex(node.parentNode, regex, checkAncestors); } - 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; - } + + for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) { + const className = (node as HTMLElement).classList[eIndex]; + if (regex.test(className)) { + return true; } - return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); } - if (node.nodeType === node.TEXT_NODE) { - // check parent node since text node do not have class name - return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); + if (!checkAncestors) return false; + return classMatchesRegex(node.parentNode, regex, checkAncestors); +} + +export function needMaskingText( + node: Node, + maskTextClass: string | RegExp, + maskTextSelector: string | null, +): boolean { + 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 (maskTextSelector) { + if (el.matches(maskTextSelector)) return true; + if (el.closest(maskTextSelector)) return true; } - 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, }); } diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index a2bde1570c..006bb2724f 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -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 { diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 5b05b7cafd..75d635e0c0 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -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(`
+ 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. +
`); + el.scrollTop = 10; + el.scrollLeft = 20; + expect(serializeNode(el)).toMatchObject({ + attributes: { + rr_scrollTop: 10, + rr_scrollLeft: 20, + }, + }); + }); +}); diff --git a/packages/rrweb-snapshot/typings/index.d.ts b/packages/rrweb-snapshot/typings/index.d.ts index efd1fb347e..194b8d3ad7 100644 --- a/packages/rrweb-snapshot/typings/index.d.ts +++ b/packages/rrweb-snapshot/typings/index.d.ts @@ -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, }; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 0cdca439df..72a7c4ca20 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -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; diff --git a/packages/rrweb/rollup.config.js b/packages/rrweb/rollup.config.js index fe4ec73171..008767236a 100644 --- a/packages/rrweb/rollup.config.js +++ b/packages/rrweb/rollup.config.js @@ -213,6 +213,19 @@ if (process.env.BROWSER_ONLY) { configs = []; + // browser record + replay, unminified (for profiling and performance testing) + configs.push({ + input: './src/index.ts', + plugins: getPlugins(), + output: [ + { + name: 'rrweb', + format: 'iife', + file: pkg.unpkg, + }, + ], + }); + for (const c of browserOnlyBaseConfigs) { configs.push({ input: c.input, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 91272adda2..aef3aa4cb4 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -308,6 +308,7 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn, this.mirror); this.shadowDomManager.observeAttachShadow(iframe); }, + newlyAddedElement: true, }); if (sn) { adds.push({ @@ -432,7 +433,10 @@ export default class MutationBuffer { switch (m.type) { case 'characterData': { const value = m.target.textContent; - if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) { + if ( + !isBlocked(m.target, this.blockClass, false) && + value !== m.oldValue + ) { this.texts.push({ value: needMaskingText( @@ -461,7 +465,10 @@ export default class MutationBuffer { maskInputFn: this.maskInputFn, }); } - if (isBlocked(m.target, this.blockClass) || value === m.oldValue) { + if ( + isBlocked(m.target, this.blockClass, false) || + value === m.oldValue + ) { return; } let item: attributeCursor | undefined = this.attributes.find( @@ -518,6 +525,11 @@ export default class MutationBuffer { break; } case 'childList': { + /** + * Parent is blocked, ignore all child mutations + */ + if (isBlocked(m.target, this.blockClass, true)) return; + m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { const nodeId = this.mirror.getId(n); @@ -525,7 +537,7 @@ export default class MutationBuffer { ? this.mirror.getId(m.target.host) : this.mirror.getId(m.target); if ( - isBlocked(m.target, this.blockClass) || + isBlocked(m.target, this.blockClass, false) || isIgnored(n, this.mirror) || !isSerialized(n, this.mirror) ) { @@ -571,19 +583,17 @@ export default class MutationBuffer { } }; + /** + * Make sure you check if `n`'s parent is blocked before calling this function + * */ private genAdds = (n: Node, target?: Node) => { - // parent was blocked, so we can ignore this node - if (target && isBlocked(target, this.blockClass)) { - return; - } - - if (this.mirror.getMeta(n)) { + if (this.mirror.hasNode(n)) { if (isIgnored(n, this.mirror)) { return; } this.movedSet.add(n); let targetId: number | null = null; - if (target && this.mirror.getMeta(target)) { + if (target && this.mirror.hasNode(target)) { targetId = this.mirror.getId(target); } if (targetId && targetId !== -1) { @@ -596,8 +606,8 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too - if (!isBlocked(n, this.blockClass)) - (n ).childNodes.forEach((childN) => this.genAdds(childN)); + if (!isBlocked(n, this.blockClass, false)) + n.childNodes.forEach((childN) => this.genAdds(childN)); }; } @@ -616,6 +626,15 @@ function isParentRemoved( removes: removedNodeMutation[], n: Node, mirror: Mirror, +): boolean { + if (removes.length === 0) return false; + return _isParentRemoved(removes, n, mirror); +} + +function _isParentRemoved( + removes: removedNodeMutation[], + n: Node, + mirror: Mirror, ): boolean { const { parentNode } = n; if (!parentNode) { @@ -625,10 +644,15 @@ function isParentRemoved( if (removes.some((r) => r.id === parentId)) { return true; } - return isParentRemoved(removes, parentNode, mirror); + return _isParentRemoved(removes, parentNode, mirror); } function isAncestorInSet(set: Set, n: Node): boolean { + if (set.size === 0) return false; + return _isAncestorInSet(set, n); +} + +function _isAncestorInSet(set: Set, n: Node): boolean { const { parentNode } = n; if (!parentNode) { return false; @@ -636,5 +660,5 @@ function isAncestorInSet(set: Set, n: Node): boolean { if (set.has(parentNode)) { return true; } - return isAncestorInSet(set, parentNode); + return _isAncestorInSet(set, parentNode); } diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index fe5830b6b6..72a6043f54 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -221,7 +221,7 @@ function initMouseInteractionObserver({ const getHandler = (eventKey: keyof typeof MouseInteractions) => { return (event: MouseEvent | TouchEvent) => { const target = getEventTarget(event) as Node; - if (isBlocked(target, blockClass)) { + if (isBlocked(target, blockClass, true)) { return; } const e = isTouchEvent(event) ? event.changedTouches[0] : event; @@ -267,7 +267,7 @@ export function initScrollObserver({ >): listenerHandler { const updatePosition = throttle((evt) => { const target = getEventTarget(evt); - if (!target || isBlocked(target as Node, blockClass)) { + if (!target || isBlocked(target as Node, blockClass, true)) { return; } const id = mirror.getId(target as Node); @@ -344,7 +344,7 @@ function initInputObserver({ !target || !(target as Element).tagName || INPUT_TAGS.indexOf((target as Element).tagName) < 0 || - isBlocked(target as Node, blockClass) + isBlocked(target as Node, blockClass, true) ) { return; } @@ -549,8 +549,8 @@ function initStyleSheetObserver( Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { unmodifiedFunctions[typeKey] = { - insertRule: (type ).prototype.insertRule, - deleteRule: (type ).prototype.deleteRule, + insertRule: type.prototype.insertRule, + deleteRule: type.prototype.deleteRule, }; type.prototype.insertRule = function (rule: string, index?: number) { @@ -653,7 +653,7 @@ function initMediaInteractionObserver({ const handler = (type: MediaInteractions) => throttle((event: Event) => { const target = getEventTarget(event); - if (!target || isBlocked(target as Node, blockClass)) { + if (!target || isBlocked(target as Node, blockClass, true)) { return; } const { currentTime, volume, muted } = target as HTMLMediaElement; diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index 6309a69e3c..488929aff3 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -36,7 +36,7 @@ export default function initCanvas2DMutationObserver( this: CanvasRenderingContext2D, ...args: Array ) { - if (!isBlocked(this.canvas, blockClass)) { + if (!isBlocked(this.canvas, blockClass, true)) { // Using setTimeout as toDataURL can be heavy // and we'd rather not block the main thread setTimeout(() => { diff --git a/packages/rrweb/src/record/observers/canvas/canvas.ts b/packages/rrweb/src/record/observers/canvas/canvas.ts index 7a5f264c5b..fe882c7315 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas.ts @@ -17,9 +17,8 @@ export default function initCanvasContextObserver( contextType: string, ...args: Array ) { - if (!isBlocked(this, blockClass)) { - if (!('__context' in this)) - (this ).__context = contextType; + if (!isBlocked(this, blockClass, true)) { + if (!('__context' in this)) this.__context = contextType; } return original.apply(this, [contextType, ...args]); }; diff --git a/packages/rrweb/src/record/observers/canvas/webgl.ts b/packages/rrweb/src/record/observers/canvas/webgl.ts index 3a70500031..89390f1e40 100644 --- a/packages/rrweb/src/record/observers/canvas/webgl.ts +++ b/packages/rrweb/src/record/observers/canvas/webgl.ts @@ -31,8 +31,7 @@ function patchGLPrototype( return function (this: typeof prototype, ...args: Array) { const result = original.apply(this, args); saveWebGLVar(result, win, prototype); - if (!isBlocked(this.canvas , blockClass)) { - const id = mirror.getId(this.canvas ); + if (!isBlocked(this.canvas, blockClass, true)) { const recordArgs = serializeArgs([...args], win, prototype); const mutation: canvasMutationWithType = { @@ -41,7 +40,7 @@ function patchGLPrototype( args: recordArgs, }; // TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement - cb(this.canvas , mutation); + cb(this.canvas, mutation); } return result; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 0b546b5fe7..ea8cd1f1b8 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -10,7 +10,7 @@ import type { textMutation, } from './types'; import type { IMirror, Mirror } from 'rrweb-snapshot'; -import { isShadowRoot, IGNORED_NODE } from 'rrweb-snapshot'; +import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; import type { RRNode, RRIFrameElement } from 'rrdom/es/virtual-dom'; export function on( @@ -180,32 +180,34 @@ export function getWindowWidth(): number { ); } -export function isBlocked(node: Node | null, blockClass: blockClass): boolean { +/** + * Checks if the given element set to be blocked by rrweb + * @param node - node to check + * @param blockClass - class name to check + * @param ignoreParents - whether to search through parent nodes for the block class + * @returns true/false if the node was blocked or not + */ +export function isBlocked( + node: Node | null, + blockClass: blockClass, + checkAncestors: boolean, +): boolean { if (!node) { return false; } - if (node.nodeType === node.ELEMENT_NODE) { - let needBlock = false; - if (typeof blockClass === 'string') { - if ((node as HTMLElement).closest !== undefined) { - return (node as HTMLElement).closest('.' + blockClass) !== null; - } else { - needBlock = (node as HTMLElement).classList.contains(blockClass); - } - } else { - (node as HTMLElement).classList.forEach((className) => { - if (blockClass.test(className)) { - needBlock = true; - } - }); - } - return needBlock || isBlocked(node.parentNode, blockClass); - } - if (node.nodeType === node.TEXT_NODE) { - // check parent node since text node do not have class name - return isBlocked(node.parentNode, blockClass); + const el: HTMLElement | null = + node.nodeType === node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + if (!el) return false; + + if (typeof blockClass === 'string') { + if (el.classList.contains(blockClass)) return true; + if (checkAncestors && el.closest('.' + blockClass) !== null) return true; + } else { + if (classMatchesRegex(el, blockClass, checkAncestors)) return true; } - return isBlocked(node.parentNode, blockClass); + return false; } export function isSerialized(n: Node, mirror: Mirror): boolean { diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 74c951afa8..e65b2372c8 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -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`] = ` "[ { diff --git a/packages/rrweb/test/benchmark/dom-mutation.test.ts b/packages/rrweb/test/benchmark/dom-mutation.test.ts index d2883ad0ae..8e446facca 100644 --- a/packages/rrweb/test/benchmark/dom-mutation.test.ts +++ b/packages/rrweb/test/benchmark/dom-mutation.test.ts @@ -1,15 +1,43 @@ // tslint:disable:no-console no-any import * as fs from 'fs'; import * as path from 'path'; +import type { Page } from 'puppeteer'; import type { eventWithTime, recordOptions } from '../../src/types'; -import { startServer, launchPuppeteer, replaceLast, ISuite } from '../utils'; +import { startServer, launchPuppeteer, ISuite, getServerURL } from '../utils'; + +const suites: Array< + { + title: string; + eval: string; + times?: number; // defaults to 5 + } & ({ html: string } | { url: string }) +> = [ + // { + // title: 'benchmarking external website', + // url: 'http://localhost:5050', + // eval: 'document.querySelector("button").click()', + // times: 10, + // }, + { + title: 'create 1000x10 DOM nodes', + html: 'benchmark-dom-mutation.html', + eval: 'window.workload()', + times: 10, + }, + { + title: 'create 1000x10x2 DOM nodes and remove a bunch of them', + html: 'benchmark-dom-mutation-add-and-remove.html', + eval: 'window.workload()', + times: 10, + }, +]; function avg(v: number[]): number { return v.reduce((prev, cur) => prev + cur, 0) / v.length; } describe('benchmark: mutation observer', () => { - let code: ISuite['code']; + jest.setTimeout(240000); let page: ISuite['page']; let browser: ISuite['browser']; let server: ISuite['server']; @@ -20,9 +48,6 @@ describe('benchmark: mutation observer', () => { dumpio: true, headless: true, }); - - const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js'); - code = fs.readFileSync(bundlePath, 'utf8'); }); afterEach(async () => { @@ -36,30 +61,19 @@ describe('benchmark: mutation observer', () => { const getHtml = (fileName: string): string => { const filePath = path.resolve(__dirname, `../html/${fileName}`); - const html = fs.readFileSync(filePath, 'utf8'); - return replaceLast( - html, - '', - ` - - - `, - ); + return fs.readFileSync(filePath, 'utf8'); }; - const suites: { - title: string; - html: string; - times?: number; // default to 5 - }[] = [ - { - title: 'create 1000x10 DOM nodes', - html: 'benchmark-dom-mutation.html', - times: 10, - }, - ]; + const addRecordingScript = async (page: Page) => { + // const scriptUrl = `${getServerURL(server)}/rrweb-1.1.3.js`; + const scriptUrl = `${getServerURL(server)}/rrweb.js`; + await page.evaluate((url) => { + const scriptEl = document.createElement('script'); + scriptEl.src = url; + document.head.append(scriptEl); + }, scriptUrl); + await page.waitForFunction('window.rrweb'); + }; for (const suite of suites) { it(suite.title, async () => { @@ -68,12 +82,19 @@ describe('benchmark: mutation observer', () => { console.log(`${message.type().toUpperCase()} ${message.text()}`), ); - const times = suite.times ?? 5; - const durations: number[] = []; - for (let i = 0; i < times; i++) { - await page.goto('about:blank'); - await page.setContent(getHtml.call(this, suite.html)); - const duration = (await page.evaluate(() => { + const loadPage = async () => { + if ('html' in suite) { + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, suite.html)); + } else { + await page.goto(suite.url); + } + + await addRecordingScript(page); + }; + + const getDuration = async (): Promise => { + return (await page.evaluate((triggerWorkloadScript) => { return new Promise((resolve, reject) => { let start = 0; let lastEvent: eventWithTime | null; @@ -94,14 +115,55 @@ describe('benchmark: mutation observer', () => { const record = (window as any).rrweb.record; record(options); - (window as any).workload(); - start = Date.now(); - setTimeout(() => { + eval(triggerWorkloadScript); + + requestAnimationFrame(() => { record.addCustomEvent('FTAG', {}); - }, 0); + }); }); - })) as number; + }, suite.eval)) as number; + }; + + // generate profile.json file + const profileFilename = `profile-${new Date().toISOString()}.json`; + const tempDirectory = path.resolve(path.join(__dirname, '../../temp')); + fs.mkdirSync(tempDirectory, { recursive: true }); + const profilePath = path.resolve(tempDirectory, profileFilename); + + const client = await page.target().createCDPSession(); + await client.send('Emulation.setCPUThrottlingRate', { rate: 6 }); + + await page.tracing.start({ + path: profilePath, + screenshots: true, + categories: [ + '-*', + 'devtools.timeline', + 'v8.execute', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'toplevel', + 'blink.console', + 'blink.user_timing', + 'latencyInfo', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + ], + }); + await loadPage(); + await getDuration(); + await page.waitForTimeout(1000); + await page.tracing.stop(); + await client.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + + // calculate durations + const times = suite.times ?? 5; + const durations: number[] = []; + for (let i = 0; i < times; i++) { + await loadPage(); + const duration = await getDuration(); durations.push(duration); } @@ -112,6 +174,7 @@ describe('benchmark: mutation observer', () => { durations: durations.join(', '), }, ]); + console.log('profile: ', profilePath); }); } }); diff --git a/packages/rrweb/test/html/benchmark-dom-mutation-add-and-remove.html b/packages/rrweb/test/html/benchmark-dom-mutation-add-and-remove.html new file mode 100644 index 0000000000..49efad4400 --- /dev/null +++ b/packages/rrweb/test/html/benchmark-dom-mutation-add-and-remove.html @@ -0,0 +1,46 @@ + + + + diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 1d36fa5ac1..969f11ff5d 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -192,6 +192,23 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + it('should record scroll position', async () => { + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + emit: ((window as unknown) as IWindow).emit, + }); + const p = document.createElement('p'); + p.innerText = 'testtesttesttesttesttesttesttesttesttest'; + p.setAttribute('style', 'overflow: auto; height: 1px; width: 1px;'); + document.body.appendChild(p); + p.scrollTop = 10; + p.scrollLeft = 10; + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + it('can add custom event', async () => { await ctx.page.evaluate(() => { const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb; diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 2eb3a6a881..89bc28453b 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -56,7 +56,11 @@ export const startServer = (defaultPort: number = 3030) => const sanitizePath = path .normalize(parsedUrl.pathname!) .replace(/^(\.\.[\/\\])+/, ''); - const pathname = path.join(__dirname, sanitizePath); + + let pathname = path.join(__dirname, sanitizePath); + if (/^\/rrweb.*\.js.*/.test(sanitizePath)) { + pathname = path.join(__dirname, `../dist`, sanitizePath); + } try { const data = fs.readFileSync(pathname); diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index 0dacfb8245..9491527c8e 100644 --- a/packages/rrweb/typings/utils.d.ts +++ b/packages/rrweb/typings/utils.d.ts @@ -10,7 +10,7 @@ export declare function patch(source: { }, name: string, replacement: (...args: any[]) => any): () => void; export declare function getWindowHeight(): number; export declare function getWindowWidth(): number; -export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean; +export declare function isBlocked(node: Node | null, blockClass: blockClass, checkAncestors: boolean): boolean; export declare function isSerialized(n: Node, mirror: Mirror): boolean; export declare function isIgnored(n: Node, mirror: Mirror): boolean; export declare function isAncestorRemoved(target: Node, mirror: Mirror): boolean; diff --git a/yarn.lock b/yarn.lock index 4a130a685d..5f85e0dff5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1560,6 +1560,21 @@ npmlog "^4.1.2" write-file-atomic "^3.0.3" +"@microsoft/tsdoc-config@0.16.1": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.1.tgz#4de11976c1202854c4618f364bf499b4be33e657" + integrity sha512-2RqkwiD4uN6MLnHFljqBlZIXlt/SaUT6cuogU1w2ARw4nKuuppSmR0+s+NC+7kXBQykd9zzu0P4HtBpZT5zBpQ== + dependencies: + "@microsoft/tsdoc" "0.14.1" + ajv "~6.12.6" + jju "~1.4.0" + resolve "~1.19.0" + +"@microsoft/tsdoc@0.14.1": + version "0.14.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd" + integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -2562,7 +2577,7 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: +ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@~6.12.6: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -4534,6 +4549,14 @@ eslint-plugin-svelte3@^4.0.0: resolved "https://registry.yarnpkg.com/eslint-plugin-svelte3/-/eslint-plugin-svelte3-4.0.0.tgz#3d4f3dcaec5761dac8bc697f81de3613b485b4e3" integrity sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g== +eslint-plugin-tsdoc@^0.2.16: + version "0.2.16" + resolved "https://registry.yarnpkg.com/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.2.16.tgz#a3d31fb9c7955faa3c66a43dd43da7635f1c5e0d" + integrity sha512-F/RWMnyDQuGlg82vQEFHQtGyWi7++XJKdYNn0ulIbyMOFqYIjoJOUdE6olORxgwgLkpJxsCJpJbTHgxJ/ggfXw== + dependencies: + "@microsoft/tsdoc" "0.14.1" + "@microsoft/tsdoc-config" "0.16.1" + eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" @@ -5908,6 +5931,13 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" +is-core-module@^2.1.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== + dependencies: + has "^1.0.3" + is-core-module@^2.2.0: version "2.5.0" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz" @@ -7137,6 +7167,11 @@ jest@^27.5.1: import-local "^3.0.2" jest-cli "^27.5.1" +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= + joycon@^3.0.1: version "3.1.1" resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"