Skip to content
Advertisement

How to put back HTMLElement into React component after the was (re)moved without React

We are wrapping a component library with React components, but in some cases the library manipulates the DOM tree in such a way that React will crash when trying to remove the React components.

Here’s a sample that reproduces the issue:

function Sample () 
{
  let [shouldRender, setShouldRender] = React.useState(true);
  return (
    <React.Fragment>
      <button onClick={() => setShouldRender(!shouldRender)}>show/hide</button>
      { shouldRender && <Component /> }
    </React.Fragment>
  );
}

function Component () 
{
  let ref = React.useRef();
  React.useEffect(() => {
    let divElement = ref.current;
    someExternalLibrary.setup(divElement);
    return () => someExternalLibrary.cleanup(divElement);
  });
  return <div ref={ref} id="div1">Hello world</div>;
}

ReactDOM.render(
  <Sample />,
  document.getElementById('container')
);

let someExternalLibrary = {
  setup: function(divElement)
  {
    let beacon = document.createElement('div');
    beacon.id = `beacon${divElement.id}`;
    divElement.parentElement.replaceChild(beacon, divElement);
    
    document.body.append(divElement);
  },
  cleanup: function(divElement)
  {
    let beacon = document.getElementById(`beacon${divElement.id}`);
    beacon.parentElement.replaceChild(divElement, beacon);
  }
}

You can find this sample on JSFiddle.

The above sample will render the Component which integrates with someExternalLibrary. The external library moves the elements from inside the React component somewhere else. Even if the external library puts back the element at its original location using a beacon, React will still complain when trying to remove the Component when you click on the show/hide button.

This will be the error

"Error: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
    at removeChildFromContainer (https://unpkg.com/react-dom@17/umd/react-dom.development.js:10337:17)
    at unmountHostComponents (https://unpkg.com/react-dom@17/umd/react-dom.development.js:21324:11)
    at commitDeletion (https://unpkg.com/react-dom@17/umd/react-dom.development.js:21377:7)
    at commitMutationEffects (https://unpkg.com/react-dom@17/umd/react-dom.development.js:23437:13)
    at HTMLUnknownElement.callCallback (https://unpkg.com/react-dom@17/umd/react-dom.development.js:3942:16)
    at Object.invokeGuardedCallbackDev (https://unpkg.com/react-dom@17/umd/react-dom.development.js:3991:18)
    at invokeGuardedCallback (https://unpkg.com/react-dom@17/umd/react-dom.development.js:4053:33)
    at commitRootImpl (https://unpkg.com/react-dom@17/umd/react-dom.development.js:23151:11)
    at unstable_runWithPriority (https://unpkg.com/react@17/umd/react.development.js:2764:14)
    at runWithPriority$1 (https://unpkg.com/react-dom@17/umd/react-dom.development.js:11306:12)"

An easy fix would be to wrap the existing HTML inside another DIV element so that becomes the root of the component, but unfortunately, that’s not always possible in our project, so I need another solution.

What would be the best approach to solving this?
Is there a way to use a ReactFragment and re-associate the HTMLElement with the fragment during cleanup?

Advertisement

Answer

The cleanup function of useEffect, hence someExternalLibrary.cleanup(), is called after React has updated the DOM (removed the div from the DOM). It fails, because React is trying to remove a DOM node that someExternalLibrary.setup() removed (and someExternalLibrary.cleanup() will put back later).

In class components you can call someExternalLibrary.cleanup() before React updates the DOM. This would fix your code:

class Component extends React.Component { 
    constructor(props) {
        super(props);
        this.divElementRef = React.createRef();
    }
    componentDidMount() {
        someExternalLibrary.setup(this.divElementRef.current);
    }
    componentWillUnmount() {
        someExternalLibrary.cleanup(this.divElementRef.current);
    }
    render() {
        return <div ref={this.divElementRef} id="div1">Hello world</div>;
    }
}

The “extra div solution” fails for the same reason: Calling someExternalLibrary.cleanup() during the cleanup of useEffect() will mean the DOM has changed, but someExternalLibrary expects no change in the DOM. By using the class component with componentWillUnmount, someExternalLibrary.setup() and someExternalLibrary.cleanup() will work with the same DOM.

Advertisement