diff --git a/.changeset/replay-domexception-refactor.md b/.changeset/replay-domexception-refactor.md new file mode 100644 index 0000000000..3c70ff0b52 --- /dev/null +++ b/.changeset/replay-domexception-refactor.md @@ -0,0 +1,6 @@ +--- +"rrweb": patch +"@rrweb/replay": patch +--- + +Make replay slightly more robust against bad mutations - replace a prior try/catch from #620 which didn't work against rrdom, and also cover appendChild failures diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 68a9938785..82a047f830 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -79,6 +79,7 @@ import { type AppendedIframe, getBaseDimension, hasShadowRoot, + canContainChildren, isSerializedIframe, getNestedRule, getPositionsAndIndex, @@ -1453,32 +1454,30 @@ export class Replayer { } // target may be removed with its parents before mirror.removeNodeFromMap(target as Node & RRNode); - if (parent) - try { - parent.removeChild(target as Node & RRNode); - /** - * https://github.com/rrweb-io/rrweb/pull/887 - * Remove any virtual style rules for stylesheets if a child text node is removed. - */ - if ( - this.usingVirtualDom && - target.nodeName === '#text' && - parent.nodeName === 'STYLE' && - (parent as RRStyleElement).rules?.length > 0 - ) - (parent as RRStyleElement).rules = []; - } catch (error) { - if (error instanceof DOMException) { - this.warn( - 'parent could not remove child in mutation', - parent, - target, - d, - ); - } else { - throw error; - } - } + if (!parent) return; + if (parent !== target.parentNode) { + this.warn( + canContainChildren(parent) + ? 'parent mismatch, cannot be used to remove node in mutation' + : 'invalid parent, cannot be used to remove node in mutation', + parent, + target, + d, + ); + return; + } + parent.removeChild(target as Node & RRNode); + /** + * https://github.com/rrweb-io/rrweb/pull/887 + * Remove any virtual style rules for stylesheets if a child text node is removed. + */ + if ( + this.usingVirtualDom && + target.nodeName === '#text' && + parent.nodeName === 'STYLE' && + (parent as RRStyleElement).rules?.length > 0 + ) + (parent as RRStyleElement).rules = []; }); const legacy_missingNodeMap: missingNodeMap = { @@ -1517,6 +1516,12 @@ export class Replayer { return this.newDocumentQueue.push(mutation); } return queue.push(mutation); + } else if (!canContainChildren(parent)) { + this.warn( + 'parent is a leaf node and cannot be used to append new child in mutation', + parent, + mutation.node, + ); } if (mutation.node.isShadow) { diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 13b18efabb..5b2855aa96 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -412,6 +412,20 @@ export function hasShadowRoot( return Boolean(dom.shadowRoot(n as unknown as Element)); } +/** + * Whether a node can contain children, i.e. is an Element, Document or + * DocumentFragment which properly support appendChild or removeChild. + * Duck typing against those methods won't work as they are in fact present + * on leaf nodes, but throw a HierarchyRequestError/DOMException when used + */ +export function canContainChildren(node: Node | RRNode): boolean { + return ( + node.nodeType === Node.ELEMENT_NODE || + node.nodeType === Node.DOCUMENT_NODE || + node.nodeType === Node.DOCUMENT_FRAGMENT_NODE + ); +} + /** * Traverses a CSSRuleList to find a nested rule at the given position. *