zoid: Removing the container element after CLOSE/DESTROY zoid event (after close()), results in uncaught error

Hi, I’ve running into this trouble and I couldn’t sort it out.

Scenario

  • Parent site using React v17.
  • User opens an HTML modal, which instantiates and renders a Zoid component (using React driver), as an iframe.
  • User Clicks on the background to close it, which internally executes zoidComponent.close() to clean the zoid frame.
  • If enough time has passed, so the iframe was already loaded and rendered properly, this results in no errors ✅.
  • But if the user opens the modal and quickly closes it, (seemingly, before the zoid iframe finished loading), then the following uncaught error is thrown 🛑.
  • This is happening because the container component from the view, is being set for removal right after the EVENTS.CLOSED zoid event.
  • What I’d expect: no error thrown when removing the modal container from the view, after the EVENTS.CLOSED (or, EVENTS.DESTROYED) event - or a way to await for the component to properly be disposed, or a way to handle/catch the error.
zoid.frameworks.frame.js:3201 Uncaught Error: Detected container element removed from DOM
    at zoid.frameworks.frame.js:3201
    at anonymous::once (zoid.frameworks.frame.js:1045)
    at elementClosed (zoid.frameworks.frame.js:3165)
    at removeChild (react-dom.development.js:10301)
    at unmountHostComponents (react-dom.development.js:21296)
    at commitDeletion (react-dom.development.js:21347)
    at commitMutationEffects (react-dom.development.js:23407)
    at HTMLUnknownElement.callCallback (react-dom.development.js:3945)
    at Object.invokeGuardedCallbackDev (react-dom.development.js:3994)
    at invokeGuardedCallback (react-dom.development.js:4056)
    at commitRootImpl (react-dom.development.js:23121)
    at unstable_runWithPriority (scheduler.development.js:646)
    at runWithPriority$1 (react-dom.development.js:11276)
    at commitRoot (react-dom.development.js:22990)
    at performSyncWorkOnRoot (react-dom.development.js:22329)
    at react-dom.development.js:11327
    at unstable_runWithPriority (scheduler.development.js:646)
    at runWithPriority$1 (react-dom.development.js:11276)
    at flushSyncCallbackQueueImpl (react-dom.development.js:11322)
    at flushSyncCallbackQueue (react-dom.development.js:11309)
    at scheduleUpdateOnFiber (react-dom.development.js:21893)
    at dispatchAction (react-dom.development.js:16139)
    at Object.onDestroy (App.tsx:61)
    at zoid.frameworks.frame.js:3320
    at zoid.frameworks.frame.js:2746
    at Function.ZalgoPromise.try (zoid.frameworks.frame.js:387)
    at _loop (zoid.frameworks.frame.js:2745)
    at Object.trigger (zoid.frameworks.frame.js:2749)
    at zoid.frameworks.frame.js:2967
    at Function.ZalgoPromise.try (zoid.frameworks.frame.js:387)
    at destroy (zoid.frameworks.frame.js:2966)
    at zoid.frameworks.frame.js:2983
    at ZalgoPromise._proto.dispatch (zoid.frameworks.frame.js:239)
    at ZalgoPromise._proto.then (zoid.frameworks.frame.js:275)
    at zoid.frameworks.frame.js:2982
    at Function.ZalgoPromise.try (zoid.frameworks.frame.js:387)
    at zoid.frameworks.frame.js:2975
    at anonymous::memoized (zoid.frameworks.frame.js:998)
    at HTMLDivElement.overlay.onclick (pluggy-connect.ts:199)

Context

Below is how the user (parent site) is triggering the “close” action of the iframe which is rendered on the modal. Then, it listens for the zoid CLOSE / DESTROY events, and after that it executes a callback so the container modal can be effectively removed from the view.

      containerTemplate({
        doc,
        dimensions: { height, width },
        close,
        uid,
        frame,
        prerenderFrame,
        event,
        props,
      }) {
// ...
        // zoid component 'close' event handler
        event.on(EVENT.CLOSE, () => {
          document.body.classList.remove(modalVisibleClassName);
          const { onClose } = props;
          // callback for the parent site so it can update the view
          if (onClose) {
            onClose();
          }
        });

        // zoid component 'destroy' event handler
        event.on(EVENT.DESTROY, () => {
          const { onDestroy } = props;
          // callback for the parent site so it can update the view
          if (onDestroy) {
            onDestroy();
          }
        });

          // overlay modal close handler 
          overlay.onclick = () => {
            close()
          };
// ...

This is how the parent app is instantiating the component:

export const MyReactZoidComponent = ({
  tag,
  url,
  ...props
}) => {
  const Zoid = useMemo(() => {
    zoid.destroy()
    return zoid.create({ tag, url }).driver("react", { React, ReactDOM })
  }, [tag, url])

  return Zoid ? <Zoid {...props} /> : null
}

And this is how the parent site renders it:

function App() {
  const [isModalOpened, setIsModalOpened] = useState(false);

  const myZoidComponentProps: MyZoidComponentProps = {
    url: REACT_APP_CONNECT_URL,
    connectToken: REACT_APP_API_KEY,
    onError: (error) => {
      console.error('Whoops! Error result... ', error);
    },
    onSuccess: (data) => {
      console.log('Yay! Success!', data);
    },
    onOpen: () => {
      console.log('Modal opened.');
    },
    onClose: () => {
      console.log('Pluggy Connect modal closed.');
      // setIsModalOpened(false);
    },
    onDestroy: () => {
      console.log('Pluggy Connect modal destroyed.');
      try {
        setIsModalOpened(false);
      } catch (error) {
        // NOTE: this catch isn't working, even though the line above shows up in the stack-trace
        console.error('whoops..', error);
      }
    },
  };

  const openModal = () => {
    setIsModalOpened(true);
  };

  return (
    <div className="App">
      <button onClick={openModal}>Open Modal</button>
      {isModalOpened && <MyReactZoidComponent {...myZoidComponentProps} />}
    </div>
  );
}

What I’ve tried

  1. Adding a try {} catch {} block arround the close() method, and also a promise .catch(). None have worked.
          overlay.onclick = () => {
            try {
              close().catch((error: unknown) =>
                console.warn('Modal close handler resulted in an error ', error)
              );
            } catch(error) {
              console.warn('Caught! Modal close handler resulted in an error ', error)
            }
          };
  1. Surrounding the <Zoid/> react component with an error boundary. Didn’t work.

**What I’d expect **

  • The zoid component close() call should not result in an unhandled error, or
  • The error should be able to be caught/handled using .catch() or try {} catch {} in some place, or…
  • A working way to properly await for the .close() / EVENTS.CLOSE action to be fully completed to let me safely remove the container component from the page.

About this issue

  • Original URL
  • State: open
  • Created 3 years ago
  • Comments: 15 (3 by maintainers)

Most upvoted comments

Any update on this?

Hi @tmilar, I was reviewing your implementation and was wondering if you could solely rely on the zoid close() function for cleaning up the DOM instead of having the React component do it. Ex:

export const MyReactZoidComponent = ({
  tag,
  url,
  ...props
}) => {
  const Zoid = useMemo(() => {
    zoid.destroy()
    return zoid.create({ tag, url }).driver("react", { React, ReactDOM })
  }, [tag, url])

  // return Zoid ? <Zoid {...props} /> : null
  // always return the zoid component here no matter what. Let the close() function be responsible for DOM cleanup.
  return <Zoid {...props} />
}

The zoid close() function should be responsible for removing the DOM of the zoid component. There are some MutationObservers being used with zoid and and calling close() cleans everything up properly. If you destroy it yourself by having React rip the component out of the DOM then you will end up seeing the error “Uncaught Error: Detected container element removed from DOM”.

One other thing to note is the close() function is async. You’ll want ensure that is done before having React remove it (ex: return Zoid ? <Zoid {...props} /> : null).

          // overlay modal close handler 
          overlay.onclick = () => {
            close().then(() => {
              console.log('closing successful!');
            });
          };