diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index eeddb03e1c..dc33455bd3 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -367,11 +367,15 @@ export class Mirror implements IMirror { // removes the node from idNodeMap // doesn't remove the node from nodeMetaMap removeNodeFromMap(n: RRNode) { - const id = this.getId(n); - this.idNodeMap.delete(id); - - if (n.childNodes) { - n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode)); + const queue = [n]; + while (queue.length > 0) { + const n = queue.pop()!; + const id = this.getId(n); + this.idNodeMap.delete(id); + + if (n.childNodes) { + n.childNodes.forEach((childNode) => queue.push(childNode)); + } } } has(id: number): boolean { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 02619296c8..1247cae27b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -262,6 +262,10 @@ export function _isBlockedElement( blockClass: string | RegExp, blockSelector: string | null, ): boolean { + if (!blockClass && !blockSelector) { + return false; + } + try { if (typeof blockClass === 'string') { if (element.classList.contains(blockClass)) { @@ -311,23 +315,27 @@ export function needMaskingText( maskTextClass: string | RegExp, maskTextSelector: string | null, ): boolean { - try { - const el: HTMLElement | null = - node.nodeType === node.ELEMENT_NODE - ? (node as HTMLElement) - : node.parentElement; - if (el === null) return false; + if (!maskTextClass && !maskTextSelector) { + return false; + } + const el: HTMLElement | null = + node.nodeType === node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + + if (el === null) return false; + try { if (typeof maskTextClass === 'string') { if (el.classList.contains(maskTextClass)) return true; - if (el.closest(`.${maskTextClass}`)) return true; + if (el.matches(`.${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; + if (el.matches(`${maskTextSelector} *`)) return true; } } catch (e) { // @@ -541,8 +549,8 @@ function serializeTextNode( // 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; + let textContent = n.textContent; const isScript = parentTagName === 'SCRIPT' ? true : undefined; if (isStyle && textContent) { try { @@ -568,15 +576,22 @@ function serializeTextNode( 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: + (maskTextFn + ? maskTextFn(textContent) + : textContent.replace(/[\S]/g, '*')) || '', + isStyle, + rootId, + }; } return { @@ -812,24 +827,40 @@ function serializeElementNode( }; } -function lowerIfExists( - maybeAttr: string | number | boolean | undefined | null, -): string { - if (maybeAttr === undefined || maybeAttr === null) { - return ''; - } else { - return (maybeAttr as string).toLowerCase(); - } -} +const MS_APPLICATION_TILE_REGEXP = /^msapplication-tile(image|color)$/; +const OG_TWITTER_OR_FB_REGEXP = /^(og|twitter|fb):/; +const OG_TWITTER_REGEXP = /^(og|twitter):/; +const ARTICLE_PRODUCT_REGEXP = /^(article|product):/; function slimDOMExcluded( sn: serializedNode, slimDOMOptions: SlimDOMOptions, ): boolean { - if (slimDOMOptions.comment && sn.type === NodeType.Comment) { + if (sn.type !== NodeType.Element && sn.type !== NodeType.Comment) { + return false; + } + + if (sn.type === NodeType.Comment && slimDOMOptions.comment) { // TODO: convert IE conditional comments to real nodes return true; - } else if (sn.type === NodeType.Element) { + } + + if (sn.type === NodeType.Element) { + /* eslint-disable */ + const snAttributeName: string = sn.attributes.name + ? // @ts-ignore + sn.attributes.name.toLowerCase() + : ''; + const snAttributeRel: string = sn.attributes.rel + ? // @ts-ignore + sn.attributes.rel.toLowerCase() + : ''; + const snAttributeProperty: string = sn.attributes.property + ? // @ts-ignore + sn.attributes.property.toLowerCase() + : ''; + /* eslint-enable */ + if ( slimDOMOptions.script && // script tag @@ -850,33 +881,30 @@ function slimDOMExcluded( slimDOMOptions.headFavicon && ((sn.tagName === 'link' && sn.attributes.rel === 'shortcut icon') || (sn.tagName === 'meta' && - (lowerIfExists(sn.attributes.name).match( - /^msapplication-tile(image|color)$/, - ) || - lowerIfExists(sn.attributes.name) === 'application-name' || - lowerIfExists(sn.attributes.rel) === 'icon' || - lowerIfExists(sn.attributes.rel) === 'apple-touch-icon' || - lowerIfExists(sn.attributes.rel) === 'shortcut icon'))) + (snAttributeName === 'application-name' || + snAttributeRel === 'icon' || + snAttributeRel === 'apple-touch-icon' || + snAttributeRel === 'shortcut icon' || + MS_APPLICATION_TILE_REGEXP.test(snAttributeName)))) ) { return true; } else if (sn.tagName === 'meta') { if ( slimDOMOptions.headMetaDescKeywords && - lowerIfExists(sn.attributes.name).match(/^description|keywords$/) + (snAttributeName === 'description' || snAttributeName === 'keywords') ) { return true; } else if ( slimDOMOptions.headMetaSocial && - (lowerIfExists(sn.attributes.property).match(/^(og|twitter|fb):/) || // og = opengraph (facebook) - lowerIfExists(sn.attributes.name).match(/^(og|twitter):/) || - lowerIfExists(sn.attributes.name) === 'pinterest') + (OG_TWITTER_OR_FB_REGEXP.test(snAttributeProperty) || // og = opengraph (facebook) + OG_TWITTER_REGEXP.test(snAttributeName)) ) { return true; } else if ( slimDOMOptions.headMetaRobots && - (lowerIfExists(sn.attributes.name) === 'robots' || - lowerIfExists(sn.attributes.name) === 'googlebot' || - lowerIfExists(sn.attributes.name) === 'bingbot') + (snAttributeName === 'robots' || + snAttributeName === 'googlebot' || + snAttributeName === 'bingbot') ) { return true; } else if ( @@ -888,24 +916,23 @@ function slimDOMExcluded( return true; } else if ( slimDOMOptions.headMetaAuthorship && - (lowerIfExists(sn.attributes.name) === 'author' || - lowerIfExists(sn.attributes.name) === 'generator' || - lowerIfExists(sn.attributes.name) === 'framework' || - lowerIfExists(sn.attributes.name) === 'publisher' || - lowerIfExists(sn.attributes.name) === 'progid' || - lowerIfExists(sn.attributes.property).match(/^article:/) || - lowerIfExists(sn.attributes.property).match(/^product:/)) + (snAttributeName === 'author' || + snAttributeName === 'generator' || + snAttributeName === 'framework' || + snAttributeName === 'publisher' || + snAttributeName === 'progid' || + ARTICLE_PRODUCT_REGEXP.test(snAttributeProperty)) ) { return true; } else if ( slimDOMOptions.headMetaVerification && - (lowerIfExists(sn.attributes.name) === 'google-site-verification' || - lowerIfExists(sn.attributes.name) === 'yandex-verification' || - lowerIfExists(sn.attributes.name) === 'csrf-token' || - lowerIfExists(sn.attributes.name) === 'p:domain_verify' || - lowerIfExists(sn.attributes.name) === 'verify-v1' || - lowerIfExists(sn.attributes.name) === 'verification' || - lowerIfExists(sn.attributes.name) === 'shopify-checkout-api-token') + (snAttributeName === 'google-site-verification' || + snAttributeName === 'yandex-verification' || + snAttributeName === 'csrf-token' || + snAttributeName === 'p:domain_verify' || + snAttributeName === 'verify-v1' || + snAttributeName === 'verification' || + snAttributeName === 'shopify-checkout-api-token') ) { return true; } @@ -1045,6 +1072,7 @@ export function serializeNodeWithId( ) { preserveWhiteSpace = false; } + const bypassOptions = { doc, mirror, @@ -1069,22 +1097,24 @@ export function serializeNodeWithId( stylesheetLoadTimeout, keepIframeSrcFn, }; - for (const childN of Array.from(n.childNodes)) { + + n.childNodes.forEach((childN) => { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } - } + }); if (isElement(n) && n.shadowRoot) { - for (const childN of Array.from(n.shadowRoot.childNodes)) { + n.shadowRoot.childNodes.forEach((childN) => { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { - isNativeShadowDom(n.shadowRoot) && - (serializedChildNode.isShadow = true); + if (isNativeShadowDom(n.shadowRoot!)) { + serializedChildNode.isShadow = true; + } serializedNode.childNodes.push(serializedChildNode); } - } + }); } } diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 95444c18b3..f8cd5daf56 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -170,13 +170,15 @@ export class Mirror implements IMirror { // removes the node from idNodeMap // doesn't remove the node from nodeMetaMap removeNodeFromMap(n: Node) { - const id = this.getId(n); - this.idNodeMap.delete(id); + const removeQueue = [n]; - if (n.childNodes) { - n.childNodes.forEach((childNode) => - this.removeNodeFromMap(childNode as unknown as Node), - ); + while (removeQueue.length) { + const node = removeQueue.pop()!; + this.idNodeMap.delete(this.getId(node)); + + if (node.childNodes) { + node.childNodes.forEach((c) => removeQueue.push(c)); + } } } has(id: number): boolean { diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index a50f27cebe..1728e7efab 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -363,6 +363,12 @@ exports[`integration tests [html file]: picture-in-frame.html 1`] = ` " `; +exports[`integration tests [html file]: picture-with-inline-onload.html 1`] = ` +" + \\"This + " +`; + exports[`integration tests [html file]: preload.html 1`] = ` " diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 097d1a8fd5..1bd540443f 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -24,7 +24,6 @@ import { isBlocked, isAncestorRemoved, isIgnored, - isSerialized, hasShadowRoot, isSerializedIframe, isSerializedStylesheet, @@ -132,6 +131,16 @@ class DoubleLinkedList { const moveKey = (id: number, parentId: number) => `${id}@${parentId}`; +const getNextId = (n: Node, mirror: observerParam['mirror']): number | null => { + let ns: Node | null = n; + let nextId: number | null = IGNORED_NODE; // slimDOM: ignored + while (nextId === IGNORED_NODE) { + ns = ns && ns.nextSibling; + nextId = ns && mirror.getId(ns); + } + return nextId; +}; + /** * controls behaviour of a MutationObserver */ @@ -256,6 +265,66 @@ export default class MutationBuffer { this.emit(); // clears buffer if not locked/frozen }; + private pushAdd( + n: Node, + adds: addedNodeMutation[], + addList: DoubleLinkedList, + ) { + if (!n.parentNode || !inDom(n)) { + return; + } + const parentId = isShadowRoot(n.parentNode) + ? this.mirror.getId(getShadowHost(n)) + : this.mirror.getId(n.parentNode); + const nextId = getNextId(n, this.mirror); + if (parentId === -1 || nextId === -1) { + return addList.addNode(n); + } + const sn = serializeNodeWithId(n, { + doc: this.doc, + mirror: this.mirror, + blockClass: this.blockClass, + blockSelector: this.blockSelector, + maskTextClass: this.maskTextClass, + maskTextSelector: this.maskTextSelector, + skipChild: true, + newlyAddedElement: true, + inlineStylesheet: this.inlineStylesheet, + maskInputOptions: this.maskInputOptions, + maskTextFn: this.maskTextFn, + maskInputFn: this.maskInputFn, + slimDOMOptions: this.slimDOMOptions, + dataURLOptions: this.dataURLOptions, + recordCanvas: this.recordCanvas, + inlineImages: this.inlineImages, + onSerialize: (currentN) => { + if (isSerializedIframe(currentN, this.mirror)) { + this.iframeManager.addIframe(currentN as HTMLIFrameElement); + } + if (isSerializedStylesheet(currentN, this.mirror)) { + this.stylesheetManager.trackLinkElement(currentN as HTMLLinkElement); + } + if (hasShadowRoot(n)) { + this.shadowDomManager.addShadowRoot(n.shadowRoot, this.doc); + } + }, + onIframeLoad: (iframe, childSn) => { + this.iframeManager.attachIframe(iframe, childSn); + this.shadowDomManager.observeAttachShadow(iframe); + }, + onStylesheetLoad: (link, childSn) => { + this.stylesheetManager.attachLinkElement(link, childSn); + }, + }); + if (sn) { + adds.push({ + parentId, + nextId, + node: sn, + }); + } + } + public emit = () => { if (this.frozen || this.locked) { return; @@ -272,76 +341,9 @@ export default class MutationBuffer { * parent, so we init a queue to store these nodes. */ const addList = new DoubleLinkedList(); - const getNextId = (n: Node): number | null => { - let ns: Node | null = n; - let nextId: number | null = IGNORED_NODE; // slimDOM: ignored - while (nextId === IGNORED_NODE) { - ns = ns && ns.nextSibling; - nextId = ns && this.mirror.getId(ns); - } - return nextId; - }; - const pushAdd = (n: Node) => { - if (!n.parentNode || !inDom(n)) { - return; - } - const parentId = isShadowRoot(n.parentNode) - ? this.mirror.getId(getShadowHost(n)) - : this.mirror.getId(n.parentNode); - const nextId = getNextId(n); - if (parentId === -1 || nextId === -1) { - return addList.addNode(n); - } - const sn = serializeNodeWithId(n, { - doc: this.doc, - mirror: this.mirror, - blockClass: this.blockClass, - blockSelector: this.blockSelector, - maskTextClass: this.maskTextClass, - maskTextSelector: this.maskTextSelector, - skipChild: true, - newlyAddedElement: true, - inlineStylesheet: this.inlineStylesheet, - maskInputOptions: this.maskInputOptions, - maskTextFn: this.maskTextFn, - maskInputFn: this.maskInputFn, - slimDOMOptions: this.slimDOMOptions, - dataURLOptions: this.dataURLOptions, - recordCanvas: this.recordCanvas, - inlineImages: this.inlineImages, - onSerialize: (currentN) => { - if (isSerializedIframe(currentN, this.mirror)) { - this.iframeManager.addIframe(currentN as HTMLIFrameElement); - } - if (isSerializedStylesheet(currentN, this.mirror)) { - this.stylesheetManager.trackLinkElement( - currentN as HTMLLinkElement, - ); - } - if (hasShadowRoot(n)) { - this.shadowDomManager.addShadowRoot(n.shadowRoot, this.doc); - } - }, - onIframeLoad: (iframe, childSn) => { - this.iframeManager.attachIframe(iframe, childSn); - this.shadowDomManager.observeAttachShadow(iframe); - }, - onStylesheetLoad: (link, childSn) => { - this.stylesheetManager.attachLinkElement(link, childSn); - }, - }); - if (sn) { - adds.push({ - parentId, - nextId, - node: sn, - }); - addedIds.add(sn.id); - } - }; while (this.mapRemoves.length) { - this.mirror.removeNodeFromMap(this.mapRemoves.shift()!); + this.mirror.removeNodeFromMap(this.mapRemoves.pop()!); } for (const n of this.movedSet) { @@ -351,7 +353,7 @@ export default class MutationBuffer { ) { continue; } - pushAdd(n); + this.pushAdd(n, adds, addList); } for (const n of this.addedSet) { @@ -359,9 +361,9 @@ export default class MutationBuffer { !isAncestorInSet(this.droppedSet, n) && !isParentRemoved(this.removes, n, this.mirror) ) { - pushAdd(n); + this.pushAdd(n, adds, addList); } else if (isAncestorInSet(this.movedSet, n)) { - pushAdd(n); + this.pushAdd(n, adds, addList); } else { this.droppedSet.add(n); } @@ -372,7 +374,7 @@ export default class MutationBuffer { let node: DoubleLinkedListNode | null = null; if (candidate) { const parentId = this.mirror.getId(candidate.value.parentNode); - const nextId = getNextId(candidate.value); + const nextId = getNextId(candidate.value, this.mirror); if (parentId !== -1 && nextId !== -1) { node = candidate; } @@ -384,12 +386,13 @@ export default class MutationBuffer { tailNode = tailNode.previous; // ensure _node is defined before attempting to find value if (_node) { + const nextId = getNextId(_node.value, this.mirror); + if (nextId === -1) continue; + const parentId = this.mirror.getId(_node.value.parentNode); - const nextId = getNextId(_node.value); - if (nextId === -1) continue; // nextId !== -1 && parentId !== -1 - else if (parentId !== -1) { + if (parentId !== -1) { node = _node; break; } @@ -427,47 +430,57 @@ export default class MutationBuffer { } candidate = node.previous; addList.removeNode(node.value); - pushAdd(node.value); + this.pushAdd(node.value, adds, addList); } - const payload = { - texts: this.texts - .map((text) => ({ - id: this.mirror.getId(text.node), - value: text.value, - })) - // no need to include them on added elements, as they have just been serialized with up to date attribubtes - .filter((text) => !addedIds.has(text.id)) - // text mutation's id was not in the mirror map means the target node has been removed - .filter((text) => this.mirror.has(text.id)), - attributes: this.attributes - .map((attribute) => { - const { attributes } = attribute; - if (typeof attributes.style === 'string') { - const diffAsStr = JSON.stringify(attribute.styleDiff); - const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); - // check if the style diff is actually shorter than the regular string based mutation - // (which was the whole point of #464 'compact style mutation'). - if (diffAsStr.length < attributes.style.length) { - // also: CSSOM fails badly when var() is present on shorthand properties, so only proceed with - // the compact style mutation if these have all been accounted for - if ( - (diffAsStr + unchangedAsStr).split('var(').length === - attributes.style.split('var(').length - ) { - attributes.style = attribute.styleDiff; - } - } + const texts = []; + for (let i = 0; i < this.texts.length; i++) { + const id = this.mirror.getId(this.texts[i].node); + if (addedIds.has(id) || !this.mirror.has(id)) { + continue; + } + texts.push({ + id, + value: this.texts[i].value, + }); + } + + const attributes = []; + for (let i = 0; i < this.attributes.length; i++) { + const id = this.mirror.getId(this.attributes[i].node); + if (addedIds.has(id) || !this.mirror.has(id)) { + continue; + } + + const { attributes: elAttributes } = this.attributes[i]; + if (typeof elAttributes.style === 'string') { + const diffAsStr = JSON.stringify(this.attributes[i].styleDiff); + const unchangedAsStr = JSON.stringify( + this.attributes[i]._unchangedStyles, + ); + // check if the style diff is actually shorter than the regular string based mutation + // (which was the whole point of #464 'compact style mutation'). + if (diffAsStr.length < elAttributes.style.length) { + // also: CSSOM fails badly when var() is present on shorthand properties, so only proceed with + // the compact style mutation if these have all been accounted for + if ( + (diffAsStr + unchangedAsStr).split('var(').length === + elAttributes.style.split('var(').length + ) { + elAttributes.style = this.attributes[i].styleDiff; } - return { - id: this.mirror.getId(attribute.node), - attributes: attributes, - }; - }) - // no need to include them on added elements, as they have just been serialized with up to date attribubtes - .filter((attribute) => !addedIds.has(attribute.id)) - // attribute mutation's id was not in the mirror map means the target node has been removed - .filter((attribute) => this.mirror.has(attribute.id)), + } + } + + attributes.push({ + id, + attributes: this.attributes[i].attributes, + }); + } + + const payload = { + texts, + attributes, removes: this.removes, adds, }; @@ -485,9 +498,9 @@ export default class MutationBuffer { this.texts = []; this.attributes = []; this.removes = []; - this.addedSet = new Set(); - this.movedSet = new Set(); - this.droppedSet = new Set(); + this.addedSet.clear(); + this.movedSet.clear(); + this.droppedSet.clear(); this.movedMap = {}; this.mutationCb(payload); @@ -497,28 +510,21 @@ export default class MutationBuffer { if (isIgnored(m.target, this.mirror)) { return; } - let unattachedDoc; - try { - // avoid upsetting original document from a Content Security point of view - unattachedDoc = document.implementation.createHTMLDocument(); - } catch (e) { - // fallback to more direct method - unattachedDoc = this.doc; - } switch (m.type) { case 'characterData': { const value = m.target.textContent; if ( - !isBlocked(m.target, this.blockClass, this.blockSelector, false) && - value !== m.oldValue + value !== m.oldValue && + !isBlocked(m.target, this.blockClass, this.blockSelector, false) ) { this.texts.push({ value: + value && needMaskingText( m.target, this.maskTextClass, this.maskTextSelector, - ) && value + ) ? this.maskTextFn ? this.maskTextFn(value) : value.replace(/[\S]/g, '*') @@ -546,15 +552,12 @@ export default class MutationBuffer { }); } if ( - isBlocked(m.target, this.blockClass, this.blockSelector, false) || - value === m.oldValue + value === m.oldValue || + isBlocked(m.target, this.blockClass, this.blockSelector, false) ) { return; } - let item: attributeCursor | undefined = this.attributes.find( - (a) => a.node === m.target, - ); if ( target.tagName === 'IFRAME' && attributeName === 'src' && @@ -568,6 +571,9 @@ export default class MutationBuffer { return; } } + let item: attributeCursor | undefined = this.attributes.find( + (a) => a.node === m.target, + ); if (!item) { item = { node: m.target, @@ -597,6 +603,14 @@ export default class MutationBuffer { value, ); if (attributeName === 'style') { + let unattachedDoc; + try { + // avoid upsetting original document from a Content Security point of view + unattachedDoc = document.implementation.createHTMLDocument(); + } catch (e) { + // fallback to more direct method + unattachedDoc = this.doc; + } const old = unattachedDoc.createElement('span'); if (m.oldValue) { old.setAttribute('style', m.oldValue); @@ -641,10 +655,11 @@ export default class MutationBuffer { const parentId = isShadowRoot(m.target) ? this.mirror.getId(m.target.host) : this.mirror.getId(m.target); + if ( - isBlocked(m.target, this.blockClass, this.blockSelector, false) || - isIgnored(n, this.mirror) || - !isSerialized(n, this.mirror) + nodeId === IGNORED_NODE || + nodeId === -1 || + isBlocked(m.target, this.blockClass, this.blockSelector, false) ) { return; } @@ -739,8 +754,13 @@ export default class MutationBuffer { * that. */ function deepDelete(addsSet: Set, n: Node) { - addsSet.delete(n); - n.childNodes.forEach((childN) => deepDelete(addsSet, childN)); + const deleteQueue = [n]; + + while (deleteQueue.length) { + const node = deleteQueue.pop()!; + addsSet.delete(node); + node.childNodes.forEach((childN) => deleteQueue.push(childN)); + } } function isParentRemoved( @@ -749,23 +769,39 @@ function isParentRemoved( mirror: Mirror, ): boolean { if (removes.length === 0) return false; - return _isParentRemoved(removes, n, mirror); + return _isParentRemoved(removes, n, mirror, new Set()); } function _isParentRemoved( removes: removedNodeMutation[], n: Node, mirror: Mirror, + removeSet: Set, ): boolean { - const { parentNode } = n; - if (!parentNode) { - return false; - } - const parentId = mirror.getId(parentNode); - if (removes.some((r) => r.id === parentId)) { - return true; + const queue = [n]; + while (queue.length) { + const { parentNode } = queue.pop()!; + + if (!parentNode) { + return false; + } + + const parentId = mirror.getId(parentNode); + if (removeSet.has(parentId)) { + return true; + } + for (let i = 0; i < removes.length; i++) { + const removedNode = removes[i]; + if (removedNode.id === parentId) { + return true; + } + removeSet.add(removedNode.id); + } + + queue.push(parentNode); } - return _isParentRemoved(removes, parentNode, mirror); + + return false; } function isAncestorInSet(set: Set, n: Node): boolean { @@ -774,12 +810,16 @@ function isAncestorInSet(set: Set, n: Node): boolean { } function _isAncestorInSet(set: Set, n: Node): boolean { - const { parentNode } = n; - if (!parentNode) { - return false; - } - if (set.has(parentNode)) { - return true; + const queue = [n]; + while (queue.length) { + const { parentNode } = queue.pop()!; + if (!parentNode) { + return false; + } + if (set.has(parentNode)) { + return true; + } + queue.push(parentNode); } - return _isAncestorInSet(set, parentNode); + return false; } diff --git a/packages/rrweb/src/record/processed-node-manager.ts b/packages/rrweb/src/record/processed-node-manager.ts index b5d6c4b679..a0ed1947ea 100644 --- a/packages/rrweb/src/record/processed-node-manager.ts +++ b/packages/rrweb/src/record/processed-node-manager.ts @@ -21,9 +21,9 @@ export default class ProcessedNodeManager { public inOtherBuffer(node: Node, thisBuffer: MutationBuffer) { const buffers = this.nodeMap.get(node); - return ( - buffers && Array.from(buffers).some((buffer) => buffer !== thisBuffer) - ); + if (!buffers) return false; + + return buffers.has(thisBuffer); } public add(node: Node, buffer: MutationBuffer) { diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 604c8810e2..c0b03b0aa5 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -232,6 +232,8 @@ export function isBlocked( if (!node) { return false; } + if (!blockClass && !blockSelector) return false; + const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) @@ -240,17 +242,20 @@ export function isBlocked( try { 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 ( + el.classList.contains(blockClass) || + (checkAncestors && el.matches(`.${blockClass} *`)) + ); } + return classMatchesRegex(el, blockClass, checkAncestors); } catch (e) { // e } if (blockSelector) { - if (el.matches(blockSelector)) return true; - if (checkAncestors && el.closest(blockSelector) !== null) return true; + return ( + el.matches(blockSelector) || + (checkAncestors && el.matches(`${blockSelector} *`)) + ); } return false; } diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index c95ec53a5f..cab44e8f7d 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -566,6 +566,12 @@ exports[`record integration tests can freeze mutations 1`] = ` \\"source\\": 0, \\"texts\\": [], \\"attributes\\": [ + { + \\"id\\": 20, + \\"attributes\\": { + \\"foo\\": \\"bar\\" + } + }, { \\"id\\": 5, \\"attributes\\": { @@ -2923,11 +2929,38 @@ exports[`record integration tests can record node mutations 1`] = ` \\"class\\": \\"select2-container select2-dropdown-open select2-container-active\\" } }, + { + \\"id\\": 36, + \\"attributes\\": { + \\"id\\": \\"select2-drop\\", + \\"style\\": \\"left: Npx; width: Npx; top: Npx; bottom: auto; display: block;\\", + \\"class\\": \\"select2-drop select2-display-none select2-with-searchbox select2-drop-active\\" + } + }, + { + \\"id\\": 70, + \\"attributes\\": { + \\"style\\": \\"\\" + } + }, + { + \\"id\\": 42, + \\"attributes\\": { + \\"class\\": \\"select2-input select2-focused\\", + \\"aria-activedescendant\\": \\"select2-result-label-2\\" + } + }, { \\"id\\": 35, \\"attributes\\": { \\"disabled\\": \\"\\" } + }, + { + \\"id\\": 72, + \\"attributes\\": { + \\"class\\": \\"select2-results-dept-0 select2-result select2-result-selectable select2-highlighted\\" + } } ], \\"removes\\": [ @@ -2983,15 +3016,6 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 28 } }, - { - \\"parentId\\": 28, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\"A\\", - \\"id\\": 29 - } - }, { \\"parentId\\": 26, \\"nextId\\": 31, @@ -3028,6 +3052,15 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 32 } }, + { + \\"parentId\\": 28, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"A\\", + \\"id\\": 29 + } + }, { \\"parentId\\": 32, \\"nextId\\": null, @@ -3078,6 +3111,30 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 38 } }, + { + \\"parentId\\": 36, + \\"nextId\\": 45, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\" \\", + \\"id\\": 44 + } + }, + { + \\"parentId\\": 36, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"ul\\", + \\"attributes\\": { + \\"class\\": \\"select2-results\\", + \\"role\\": \\"listbox\\", + \\"id\\": \\"select2-results-1\\" + }, + \\"childNodes\\": [], + \\"id\\": 45 + } + }, { \\"parentId\\": 38, \\"nextId\\": 40, @@ -3144,30 +3201,6 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 43 } }, - { - \\"parentId\\": 36, - \\"nextId\\": 45, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\" \\", - \\"id\\": 44 - } - }, - { - \\"parentId\\": 36, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"ul\\", - \\"attributes\\": { - \\"class\\": \\"select2-results\\", - \\"role\\": \\"listbox\\", - \\"id\\": \\"select2-results-1\\" - }, - \\"childNodes\\": [], - \\"id\\": 45 - } - }, { \\"parentId\\": 45, \\"nextId\\": null, @@ -4582,7 +4615,15 @@ exports[`record integration tests handles null attribute values 1`] = ` \\"data\\": { \\"source\\": 0, \\"texts\\": [], - \\"attributes\\": [], + \\"attributes\\": [ + { + \\"id\\": 20, + \\"attributes\\": { + \\"aria-label\\": \\"label\\", + \\"id\\": \\"test-li\\" + } + } + ], \\"removes\\": [], \\"adds\\": [ { @@ -9193,6 +9234,15 @@ exports[`record integration tests should not record input values if dynamically \\"id\\": 21 } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 3, + \\"id\\": 21, + \\"x\\": 16.5, + \\"y\\": 0 + } + }, { \\"type\\": 3, \\"data\\": { @@ -10284,6 +10334,15 @@ exports[`record integration tests should record DOM node movement 1 1`] = ` \\"id\\": 14 } }, + { + \\"parentId\\": 12, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + } + }, { \\"parentId\\": 14, \\"nextId\\": 16, @@ -10304,15 +10363,6 @@ exports[`record integration tests should record DOM node movement 1 1`] = ` \\"id\\": 16 } }, - { - \\"parentId\\": 16, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\"1\\", - \\"id\\": 17 - } - }, { \\"parentId\\": 14, \\"nextId\\": null, @@ -10323,12 +10373,12 @@ exports[`record integration tests should record DOM node movement 1 1`] = ` } }, { - \\"parentId\\": 12, + \\"parentId\\": 16, \\"nextId\\": null, \\"node\\": { \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", - \\"id\\": 19 + \\"textContent\\": \\"1\\", + \\"id\\": 17 } } ] @@ -10540,6 +10590,15 @@ exports[`record integration tests should record DOM node movement 2 1`] = ` \\"id\\": 14 } }, + { + \\"parentId\\": 12, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + } + }, { \\"parentId\\": 14, \\"nextId\\": 16, @@ -10560,15 +10619,6 @@ exports[`record integration tests should record DOM node movement 2 1`] = ` \\"id\\": 16 } }, - { - \\"parentId\\": 16, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\"1\\", - \\"id\\": 17 - } - }, { \\"parentId\\": 14, \\"nextId\\": null, @@ -10579,12 +10629,12 @@ exports[`record integration tests should record DOM node movement 2 1`] = ` } }, { - \\"parentId\\": 12, + \\"parentId\\": 16, \\"nextId\\": null, \\"node\\": { \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", - \\"id\\": 19 + \\"textContent\\": \\"1\\", + \\"id\\": 17 } }, { @@ -13907,18 +13957,6 @@ exports[`record integration tests should record moved shadow DOM 1`] = ` \\"id\\": 9, \\"isShadowHost\\": true } - }, - { - \\"parentId\\": 9, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 10, - \\"isShadow\\": true - } } ] } @@ -13936,18 +13974,6 @@ exports[`record integration tests should record moved shadow DOM 1`] = ` } ], \\"adds\\": [ - { - \\"parentId\\": 9, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 10, - \\"isShadow\\": true - } - }, { \\"parentId\\": 4, \\"nextId\\": null, @@ -13956,11 +13982,11 @@ exports[`record integration tests should record moved shadow DOM 1`] = ` \\"tagName\\": \\"div\\", \\"attributes\\": {}, \\"childNodes\\": [], - \\"id\\": 11 + \\"id\\": 10 } }, { - \\"parentId\\": 11, + \\"parentId\\": 10, \\"nextId\\": null, \\"node\\": { \\"type\\": 2, @@ -14091,68 +14117,6 @@ exports[`record integration tests should record moved shadow DOM 2 1`] = ` \\"id\\": 10, \\"isShadowHost\\": true } - }, - { - \\"parentId\\": 10, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"p\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 11, - \\"isShadow\\": true - } - }, - { - \\"parentId\\": 10, - \\"nextId\\": 11, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"span\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 12, - \\"isShadow\\": true - } - }, - { - \\"parentId\\": 12, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 13 - } - } - ] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [], - \\"removes\\": [ - { - \\"parentId\\": 12, - \\"id\\": 13 - } - ], - \\"adds\\": [ - { - \\"parentId\\": 11, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 13 - } } ] } @@ -14789,6 +14753,14 @@ exports[`record integration tests should record nested iframes and shadow doms 1 \\"isAttachIframe\\": true } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 4, + \\"width\\": 1920, + \\"height\\": 1080 + } + }, { \\"type\\": 3, \\"data\\": { @@ -15365,18 +15337,6 @@ exports[`record integration tests should record shadow DOM 2 1`] = ` \\"id\\": 9, \\"isShadowHost\\": true } - }, - { - \\"parentId\\": 9, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 10, - \\"isShadow\\": true - } } ] } @@ -15483,18 +15443,6 @@ exports[`record integration tests should record shadow DOM 3 1`] = ` \\"id\\": 9, \\"isShadowHost\\": true } - }, - { - \\"parentId\\": 9, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"input\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 10, - \\"isShadow\\": true - } } ] } @@ -16003,7 +15951,18 @@ exports[`record integration tests should record shadow doms polyfilled by synthe \\"id\\": 31, \\"isShadowHost\\": true } - }, + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ { \\"parentId\\": 31, \\"nextId\\": null, diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index cb40f3328d..cbf073362b 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -3514,8 +3514,20 @@ exports[`record no need for attribute mutations on adds 1`] = ` \\"type\\": 3, \\"data\\": { \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [], + \\"texts\\": [ + { + \\"id\\": 10, + \\"value\\": \\"y\\" + } + ], + \\"attributes\\": [ + { + \\"id\\": 9, + \\"attributes\\": { + \\"data-test\\": \\"x\\" + } + } + ], \\"removes\\": [], \\"adds\\": [ { diff --git a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap index 07d4f53363..bbb3d9df31 100644 --- a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap @@ -4069,6 +4069,16 @@ exports[`cross origin iframes move-node.html should record DOM node movement 1`] \\"id\\": 26 } }, + { + \\"parentId\\": 24, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 31 + } + }, { \\"parentId\\": 26, \\"nextId\\": 28, @@ -4091,16 +4101,6 @@ exports[`cross origin iframes move-node.html should record DOM node movement 1`] \\"id\\": 28 } }, - { - \\"parentId\\": 28, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\"1\\", - \\"rootId\\": 11, - \\"id\\": 29 - } - }, { \\"parentId\\": 26, \\"nextId\\": null, @@ -4112,13 +4112,13 @@ exports[`cross origin iframes move-node.html should record DOM node movement 1`] } }, { - \\"parentId\\": 24, + \\"parentId\\": 28, \\"nextId\\": null, \\"node\\": { \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", + \\"textContent\\": \\"1\\", \\"rootId\\": 11, - \\"id\\": 31 + \\"id\\": 29 } }, {