From c72ee27aa406066ed10dfd72a0cba32577b30d2d Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Tue, 19 Mar 2024 16:29:53 +0500 Subject: [PATCH] editor: fix DOMException when removing react node views --- .../src/extensions/react/react-node-view.tsx | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/extensions/react/react-node-view.tsx b/packages/editor/src/extensions/react/react-node-view.tsx index 4273ca3da..f5e026021 100644 --- a/packages/editor/src/extensions/react/react-node-view.tsx +++ b/packages/editor/src/extensions/react/react-node-view.tsx @@ -46,12 +46,32 @@ export class ReactNodeView

implements NodeView { private domRef!: HTMLElement; private contentDOMWrapper?: Node; - contentDOM: HTMLElement | undefined; + contentDOM?: HTMLElement; node: PMNode; isDragging = false; selected = false; pos = -1; posEnd: number | undefined; + + // in order to cleanly unmount a React Portal, we have to preserve the + // dom structure for the full node. However, Prosemirror/browser can, + // sometimes, detach the node before React is able to call "unmount". + // Unmounting a React child whose DOM counterpart is already removed + // results in a DOMException. + // To fix that, we observe and store all the detached subnodes and later add + // them back when we are ready to destroy our node view once & for all. + // This wouldn't be necessary if React allowed for a way to "sync" the DOM + // changes to its Virtual DOM. + detached: Set = new Set(); + detachObserver = new MutationObserver((mutations) => { + const filtered = mutations.filter( + (m) => m.target === this.domRef && m.removedNodes.length > 0 + ); + for (const mutation of filtered) { + for (const node of mutation.removedNodes) this.detached.add(node); + } + }); + constructor( node: PMNode, protected readonly editor: Editor, @@ -85,6 +105,10 @@ export class ReactNodeView

implements NodeView { init() { this.domRef = this.createDomRef(); this.domRef.ondragstart = (ev) => this.onDragStart(ev); + this.detachObserver.observe(this.domRef, { + childList: true, + subtree: true + }); // this.setDomAttrs(this.node, this.domRef); const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {}; @@ -471,8 +495,15 @@ export class ReactNodeView

implements NodeView { } destroy() { - if (!this.portalProviderAPI) return; + this.detachObserver.disconnect(); + // add back the detached nodes because React expects an untouched + // DOM representation (and there's no way to reconcile the DOM later). + for (const node of this.detached) { + this.domRef.appendChild(node); + } this.portalProviderAPI.remove(this.domRef); + this.detached.clear(); + this.domRef.remove(); } }