Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

unmount an empty component is breaking with ReactDOM portals #14811

Closed
KhodorAmmar opened this issue Feb 10, 2019 · 21 comments
Closed

unmount an empty component is breaking with ReactDOM portals #14811

KhodorAmmar opened this issue Feb 10, 2019 · 21 comments

Comments

@KhodorAmmar
Copy link

KhodorAmmar commented Feb 10, 2019

Do you want to request a feature or report a bug? Bug

What is the current behavior? When unmounting a component that has a child being rendered under a different parent (with portals), react is throwing an error

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem:

https://codesandbox.io/s/73n31lwpjx

What is the expected behavior?

Component should unmount normally

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?
16.8.1
Issue also happens with 16.7.0 (https://codesandbox.io/s/oxmpxmllvy)

The issue is only happening under very strict conditions:

  • The component being rendered with ReactDOM Portals (Modal) should not render any HTML
  • The parent component (Panel) should render Modal as the first component under <React.Fragment>

Avoiding this is as simple as moving Modal under some other HTML. I'm not entirely sure this is an issue or I'm just doing something wrong with Fragment and portals.

The actual error being thrown is:
react-dom.development.js:9254 Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

@gaearon
Copy link
Collaborator

gaearon commented Feb 10, 2019

Seems like the same as #14434.

Want to look into it?

@KhodorAmmar
Copy link
Author

Sure, I might need guidance into where exactly to start looking though

@gaearon
Copy link
Collaborator

gaearon commented Feb 10, 2019

I'm guessing it's somewhere here

function commitNestedUnmounts(root: Fiber): void {
// While we're inside a removed host node we don't want to call
// removeChild on the inner nodes because they're removed by the top
// call anyway. We also want to call componentWillUnmount on all
// composites before this host node is removed from the tree. Therefore
// we do an inner loop while we're still inside the host node.
let node: Fiber = root;
while (true) {
commitUnmount(node);
// Visit children because they may contain more composite or host nodes.
// Skip portals because commitUnmount() currently visits them recursively.
if (
node.child !== null &&
// If we use mutation we drill down into portals using commitUnmount above.
// If we don't use mutation we drill down into portals here instead.
(!supportsMutation || node.tag !== HostPortal)
) {
node.child.return = node;
node = node.child;
continue;
}
if (node === root) {
return;
}
while (node.sibling === null) {
if (node.return === null || node.return === root) {
return;
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
}

Or here:

function unmountHostComponents(current): void {
// We only have the top Fiber that was deleted but we need to recurse down its
// children to find all the terminal nodes.
let node: Fiber = current;
// Each iteration, currentParent is populated with node's host parent if not
// currentParentIsValid.
let currentParentIsValid = false;
// Note: these two variables *must* always be updated together.
let currentParent;
let currentParentIsContainer;
while (true) {
if (!currentParentIsValid) {
let parent = node.return;
findParent: while (true) {
invariant(
parent !== null,
'Expected to find a host parent. This error is likely caused by ' +
'a bug in React. Please file an issue.',
);
switch (parent.tag) {
case HostComponent:
currentParent = parent.stateNode;
currentParentIsContainer = false;
break findParent;
case HostRoot:
currentParent = parent.stateNode.containerInfo;
currentParentIsContainer = true;
break findParent;
case HostPortal:
currentParent = parent.stateNode.containerInfo;
currentParentIsContainer = true;
break findParent;
}
parent = parent.return;
}
currentParentIsValid = true;
}
if (node.tag === HostComponent || node.tag === HostText) {
commitNestedUnmounts(node);
// After all the children have unmounted, it is now safe to remove the
// node from the tree.
if (currentParentIsContainer) {
removeChildFromContainer((currentParent: any), node.stateNode);
} else {
removeChild((currentParent: any), node.stateNode);
}
// Don't visit children because we already visited them.
} else if (node.tag === HostPortal) {
// When we go into a portal, it becomes the parent to remove from.
// We will reassign it back when we pop the portal on the way up.
currentParent = node.stateNode.containerInfo;
currentParentIsContainer = true;
// Visit children because portals might contain host components.
if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
} else {
commitUnmount(node);
// Visit children because we may find more host components below.
if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
}
if (node === current) {
return;
}
while (node.sibling === null) {
if (node.return === null || node.return === current) {
return;
}
node = node.return;
if (node.tag === HostPortal) {
// When we go out of the portal, we need to restore the parent.
// Since we don't keep a stack of them, we will search for it.
currentParentIsValid = false;
}
}
node.sibling.return = node.return;
node = node.sibling;
}
}

@KhodorAmmar
Copy link
Author

Okay then I’ll take a look

@gaearon
Copy link
Collaborator

gaearon commented Feb 10, 2019

You might also want to start by making a failing test. Here's some existing tests for portals.

it('should reconcile portal children', () => {
const portalContainer = document.createElement('div');
ReactDOM.render(
<div>{ReactDOM.createPortal(<div>portal:1</div>, portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('<div>portal:1</div>');
expect(container.innerHTML).toBe('<div></div>');
ReactDOM.render(
<div>{ReactDOM.createPortal(<div>portal:2</div>, portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('<div>portal:2</div>');
expect(container.innerHTML).toBe('<div></div>');
ReactDOM.render(
<div>{ReactDOM.createPortal(<p>portal:3</p>, portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('<p>portal:3</p>');
expect(container.innerHTML).toBe('<div></div>');
ReactDOM.render(
<div>{ReactDOM.createPortal(['Hi', 'Bye'], portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('HiBye');
expect(container.innerHTML).toBe('<div></div>');
ReactDOM.render(
<div>{ReactDOM.createPortal(['Bye', 'Hi'], portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('ByeHi');
expect(container.innerHTML).toBe('<div></div>');
ReactDOM.render(
<div>{ReactDOM.createPortal(null, portalContainer)}</div>,
container,
);
expect(portalContainer.innerHTML).toBe('');
expect(container.innerHTML).toBe('<div></div>');
});

KhodorAmmar added a commit to KhodorAmmar/react that referenced this issue Feb 11, 2019
@KhodorAmmar
Copy link
Author

KhodorAmmar commented Feb 11, 2019

Would this work? the test creates a component with the same criteria as the demo above and removes the child component via a state update. It currently breaks

https://github.com/KhodorAmmar/react/blob/1aa3167b15b2eaa74f9afeac16d498a781b8c09b/packages/react-dom/src/__tests__/ReactDOMFiber-test.js#L464-L496

Also is ReactDOMFiber-test the correct place for the test?

@KhodorAmmar
Copy link
Author

Ok I was debugging the code and I believe I can pinpoint where is the issue coming from:

} else if (node.tag === HostPortal) {
// When we go into a portal, it becomes the parent to remove from.
// We will reassign it back when we pop the portal on the way up.
currentParent = node.stateNode.containerInfo;
currentParentIsContainer = true;
// Visit children because portals might contain host components.
if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
} else {
commitUnmount(node);
// Visit children because we may find more host components below.
if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
}
if (node === current) {
return;
}
while (node.sibling === null) {
if (node.return === null || node.return === current) {
return;
}
node = node.return;
if (node.tag === HostPortal) {
// When we go out of the portal, we need to restore the parent.
// Since we don't keep a stack of them, we will search for it.
currentParentIsValid = false;
}
}

When the node is a HostPortal, and it is rendering nothing, and it is the first child under a Fragment, the currentParentIsValid is not being reset to false correctly in line 1071

Adding currentParentIsValid = false; in line 1044 is fixing the issue, all other tests are still passing.

I just feel that it's a too general solution? Am I on the right path at least?

gaearon pushed a commit to gaearon/react that referenced this issue Feb 11, 2019
@gaearon
Copy link
Collaborator

gaearon commented Feb 11, 2019

Thanks, this was very helpful. I sent a fix in #14820.

gaearon added a commit that referenced this issue Feb 11, 2019
* Adds failing test for #14811

* Fix removeChild() crash when removing an empty Portal
@gaearon
Copy link
Collaborator

gaearon commented Feb 13, 2019

Canary 0.0.0-0e4135e8c should have the fix. This will make it into 16.8.2.

@KhodorAmmar
Copy link
Author

Can confirm it works in demo and my real life project

@gaearon
Copy link
Collaborator

gaearon commented Feb 14, 2019

Fixed in 16.8.2

@278kunal
Copy link

Still facing this in 16.8.6

@dcporter44
Copy link

@278kunal I was also receiving this same error in 16.8.6. However, I just discovered that I was receiving this error for a different reason than mentioned in this thread. I was using createPortal to append my component to a sibling container (Bad practice, but I had to do this as a workaround with a 3rd party library I'm using). My code looked something like this:

render() { return ( <div> <div id="container"></div> {ReactDOM.createPortal(<div>Append Me</div>, document.getElementById("container")) </div> ) }

When the page unmounted, React removed the container before it removed the actual portal. I fixed this by simply reordering the createPortal before the sibling container I was appending to. That way React destroys the portal prior to the container. So it looks like this:

render() { return ( <div> {ReactDOM.createPortal(<div>Append Me</div>, document.getElementById("container")) <div id="container"></div> </div> ) }

Just thought this might help someone else :)

@efleury1
Copy link

efleury1 commented Feb 25, 2020

@dcporter44 You're an absolute life-saver. Had exactly the same issue (injecting into thirdy-party sibling) - switching the rendering order fixed it for me too. Thanks!

@jsphstls
Copy link

I still see this in 16.12.

Repro: https://codesandbox.io/s/portal-remove-child-error-sbnj2

@tommedema
Copy link

Some of our users are getting this error sporadically in production (NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.), but I've been unable to reproduce it. We don't use portal directly but perhaps one of our dependencies does.

@talolard
Copy link

Some of our users are getting this error sporadically in production (NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.), but I've been unable to reproduce it. We don't use portal directly but perhaps one of our dependencies does.

Same here

@tayan-serna
Copy link

I did a little validation to avoid the crash.

 useLayoutEffect(() => {
    modalRoot.appendChild(el);
    return () => {
      // check if modalRoot have children
      if (modalRoot.children.length) {
        modalRoot.removeChild(el);
      }
    };
  }, [visible]);

In this way, my app is not crashing.

@hgeldenhuys
Copy link

hgeldenhuys commented May 15, 2020

I also started getting this, but in the strangest place. At first I thought it was in react-sortablejs, since it does DOM manipulation, but in React 16.12.0 it worked, and gave this error in 16.13.1.

Turns out I was doing something wrong with useSelector in Redux. I basically returned a copy of my redux state, so the selector subscription was changed every time a rerender was triggered. Wasn't an obvious fix, I really had to backtrack and go through a lot of things and fixed my useSelectors with proper selector function.

Think in my case it was zombie children:
https://react-redux.js.org/api/hooks#usage-warnings

@jsphstls
Copy link

Validation cannot actually prevent the crash. As @gaearon says here, React:

repeats what you’ve already done, causing failures

React does not repeat the validation, only the removal.

@xianghongai
Copy link

xianghongai commented Oct 22, 2020

Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

fix:

   unmountContainer() {
      // macro task
      setTimeout(() => {
        // refs
        if (containerEle) {
          // receive removed node
          // eslint-disable-next-line no-unused-vars
          let removedChild = document
            .querySelector('.page__wrapper')
            .removeChild(containerEle);
        }
      });
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests