From 731f950113fbdb4fb10fcce950b243a106266262 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 17 Jul 2026 20:14:08 +0100 Subject: [PATCH 1/8] Modification to #620 - the only thing this try/catch was intended to warn against was when parent incorrectly resolved to a #text node (a logical error in the fullsnapshot + mutation stream). The explicit instanceof DOMException did not work with rrdom. Replace with an explicit nodeType check --- packages/rrweb/src/replay/index.ts | 49 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 68a9938785..55829ea4fc 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1453,32 +1453,29 @@ 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; + } else if (parent.nodeType !== 1 && parent.nodeType !== 9 && parent.nodeType !== 11) { + this.warn( + "parent is a leaf node and cannot remove child 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 = { From 01c5a9d7e68e8380126e0da779cf43b16bfcfea8 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 09:48:17 +0100 Subject: [PATCH 2/8] The previous DOMException catch also covered the case when the parentId is not the parent; both are caused by the same scenario - applying mutations to the 'wrong' replay state (against a different fullsnapshot or a prior mutation is missing) --- packages/rrweb/src/replay/index.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 55829ea4fc..8115a61973 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1453,15 +1453,10 @@ export class Replayer { } // target may be removed with its parents before mirror.removeNodeFromMap(target as Node & RRNode); - if (!parent) { + if (!parent) return; - } else if (parent.nodeType !== 1 && parent.nodeType !== 9 && parent.nodeType !== 11) { - this.warn( - "parent is a leaf node and cannot remove child in mutation", - parent, - target, - d, - ); + if (parent !== target.parentNode) { + this.warn("parent child mismatch in mutation", parent, target, d); return; } parent.removeChild(target as Node & RRNode); @@ -1514,6 +1509,12 @@ export class Replayer { return this.newDocumentQueue.push(mutation); } return queue.push(mutation); + } else if (parent.nodeType !== 1 && parent.nodeType !== 9 && parent.nodeType !== 11) { + this.warn( + "parent is a leaf node and cannot be used to append a child", + parent, + mutation.node, + ); } if (mutation.node.isShadow) { From 233c8b8eed7f7071c4bbd7fd0fb152f66f465a47 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 09:58:54 +0100 Subject: [PATCH 3/8] Add changeset --- .changeset/replay-domexception-refactor.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/replay-domexception-refactor.md diff --git a/.changeset/replay-domexception-refactor.md b/.changeset/replay-domexception-refactor.md new file mode 100644 index 0000000000..ae3b9f3f32 --- /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 From a2d836bcd59141f35a000ce12d4584e28526b8e9 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 10:07:15 +0100 Subject: [PATCH 4/8] Extract container types for documentation and for succinctness --- packages/rrweb/src/replay/index.ts | 9 +++++++-- packages/rrweb/src/utils.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 8115a61973..2a0106ea19 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, @@ -1456,7 +1457,11 @@ export class Replayer { if (!parent) return; if (parent !== target.parentNode) { - this.warn("parent child mismatch in mutation", parent, target, d); + if (!canContainChildren(parent)) { + this.warn('invalid parent, cannot be used to remove node', parent, target, d); + } else { + this.warn('parent mismatch, cannot be used to remove node', parent, target, d); + } return; } parent.removeChild(target as Node & RRNode); @@ -1509,7 +1514,7 @@ export class Replayer { return this.newDocumentQueue.push(mutation); } return queue.push(mutation); - } else if (parent.nodeType !== 1 && parent.nodeType !== 9 && parent.nodeType !== 11) { + } else if (!canContainChildren(parent)) { this.warn( "parent is a leaf node and cannot be used to append a child", parent, 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. * From 7bfc0f34bf98a835ce7ed0d9ec62f7f26179c122 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 10:24:31 +0100 Subject: [PATCH 5/8] yarn:format fixes --- packages/rrweb/src/replay/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 2a0106ea19..19e3f2ddb5 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1454,8 +1454,7 @@ export class Replayer { } // target may be removed with its parents before mirror.removeNodeFromMap(target as Node & RRNode); - if (!parent) - return; + if (!parent) return; if (parent !== target.parentNode) { if (!canContainChildren(parent)) { this.warn('invalid parent, cannot be used to remove node', parent, target, d); @@ -1471,9 +1470,9 @@ export class Replayer { */ if ( this.usingVirtualDom && - target.nodeName === '#text' && - parent.nodeName === 'STYLE' && - (parent as RRStyleElement).rules?.length > 0 + target.nodeName === '#text' && + parent.nodeName === 'STYLE' && + (parent as RRStyleElement).rules?.length > 0 ) (parent as RRStyleElement).rules = []; }); @@ -1516,7 +1515,7 @@ export class Replayer { return queue.push(mutation); } else if (!canContainChildren(parent)) { this.warn( - "parent is a leaf node and cannot be used to append a child", + 'parent is a leaf node and cannot be used to append a child', parent, mutation.node, ); From 546dfffbd179274e9cb7fb4397d380750e89c1de Mon Sep 17 00:00:00 2001 From: eoghanmurray Date: Sat, 18 Jul 2026 09:29:07 +0000 Subject: [PATCH 6/8] Apply formatting changes --- .changeset/replay-domexception-refactor.md | 4 ++-- packages/rrweb/src/replay/index.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.changeset/replay-domexception-refactor.md b/.changeset/replay-domexception-refactor.md index ae3b9f3f32..3c70ff0b52 100644 --- a/.changeset/replay-domexception-refactor.md +++ b/.changeset/replay-domexception-refactor.md @@ -1,6 +1,6 @@ --- -'rrweb': patch -'@rrweb/replay': patch +"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 19e3f2ddb5..110f8b25e2 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1457,9 +1457,19 @@ export class Replayer { if (!parent) return; if (parent !== target.parentNode) { if (!canContainChildren(parent)) { - this.warn('invalid parent, cannot be used to remove node', parent, target, d); + this.warn( + 'invalid parent, cannot be used to remove node', + parent, + target, + d, + ); } else { - this.warn('parent mismatch, cannot be used to remove node', parent, target, d); + this.warn( + 'parent mismatch, cannot be used to remove node', + parent, + target, + d, + ); } return; } From c51c64f79bff59a1baafcf866f81da6aefd64797 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 10:41:15 +0100 Subject: [PATCH 7/8] Fix awkward multiline --- packages/rrweb/src/replay/index.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 110f8b25e2..1ccd1517aa 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1456,21 +1456,14 @@ export class Replayer { mirror.removeNodeFromMap(target as Node & RRNode); if (!parent) return; if (parent !== target.parentNode) { - if (!canContainChildren(parent)) { - this.warn( - 'invalid parent, cannot be used to remove node', - parent, - target, - d, - ); - } else { - this.warn( - 'parent mismatch, cannot be used to remove node', - parent, - target, - d, - ); - } + 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); From 2b5bd50f39a04e6561d9b1cbf79684638610de11 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Sat, 18 Jul 2026 10:42:13 +0100 Subject: [PATCH 8/8] Echo prior 'parent could not remove child in mutation' --- packages/rrweb/src/replay/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 1ccd1517aa..82a047f830 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1518,7 +1518,7 @@ export class Replayer { return queue.push(mutation); } else if (!canContainChildren(parent)) { this.warn( - 'parent is a leaf node and cannot be used to append a child', + 'parent is a leaf node and cannot be used to append new child in mutation', parent, mutation.node, );