Skip to content

fix: avoid NotFoundError when the web measurement element is already detached - #746

Open
wildan-m wants to merge 1 commit into
appandflow:mainfrom
wildan-m:fix/web-cleanup-removechild-detached-element
Open

fix: avoid NotFoundError when the web measurement element is already detached#746
wildan-m wants to merge 1 commit into
appandflow:mainfrom
wildan-m:fix/web-cleanup-removechild-detached-element

Conversation

@wildan-m

Copy link
Copy Markdown

Summary

On web, NativeSafeAreaProvider appends a hidden measurement element to document.body when it mounts, and its effect cleanup removes it again:

return () => {
  document.body.removeChild(element);
  element.removeEventListener(getSupportedTransitionEvent(), onEnd);
};

Node.removeChild() throws NotFoundError when the node is not currently a child of the node it is called on. So if anything detaches that element before the cleanup runs — a browser extension, the browser's page-translation layer, or any third-party script that mutates document.body — the cleanup throws. Because it runs inside React's passive-unmount chain, nothing catches it and it surfaces as an uncaught error:

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

This changes the cleanup to detach the listener first and then use element.remove(), which is a no-op when the node has no parent:

return () => {
  element.removeEventListener(getSupportedTransitionEvent(), onEnd);
  element.remove();
};

Two small notes on the shape of the fix:

  • Removing the listener first means it is always detached even in the case that previously threw, so there is no listener left behind.
  • element.remove() is preferred over guarding with document.body.contains(element) because it also handles the case where the element was re-parented rather than removed.

Behaviour is unchanged when the element is still attached — it is removed exactly as before. Only the previously-throwing path differs.

We hit this in production at Expensify, reported via Sentry: Expensify/App#95343

Test Plan

yarn test passes (prettier, eslint, tsc, jest).

The failing path is a teardown race, so it has to be set up explicitly. Mount the provider, detach the measurement element the way an external script would, then unmount so React runs the cleanup:

const {unmount} = render(<NativeSafeAreaProvider onInsetsChange={() => {}} />);

// the element the provider added to <body>
const el = [...document.body.children].find(
  (n) => n.style?.position === 'fixed' && n.style?.transitionProperty === 'padding',
);

el.remove();        // something else detached it first
unmount();          // React runs the cleanup
  • Before this change: unmount() throws NotFoundError: The node to be removed is not a child of this node.
  • After this change: unmount() completes with no error.

Running the same steps with the element left attached succeeds both before and after, which confirms the normal path is untouched.

…ched

The web provider removes its hidden measurement element with
document.body.removeChild(element), which throws NotFoundError when the element
is no longer a child of body. Anything that mutates document.body — an
extension, a page-translation layer, a third-party script — can detach it before
the effect cleanup runs, and the throw escapes React's passive-unmount chain as
an uncaught error.

Detach the listener first, then use element.remove(), which is a no-op when the
node has no parent. The attached case is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant