Skip to content

[EuiFlyout] type="push" throws NotFoundError on unmount during concurrent DOM operations #9389

@lucaslopezf

Description

@lucaslopezf

TL;DR

Same error as this issue different behavior.

In PR #251637, with the code as-is:

  1. Open Discover in xl viewport and run a metrics ES|QL query (e.g. TS metrics-* or TS remote_cluster:metrics-*)
  2. Open the Metric Insights flyout on any metric
  3. Duplicate the tab (flyout state is preserved in the new tab)
  4. Close the duplicated tab
  5. Console error: NotFoundError: Failed to execute 'insertBefore' on 'Node'

Changing the flyout from type="push" to type="overlay" eliminates the error completely. The root cause is that EuiFlyout in push mode calls setGlobalCSSVariables inside a useLayoutEffect (synchronous), which triggers a nested Emotion <Global> re-render during the same React commit where react-reverse-portal is doing replaceChild. The stale DOM references cause the crash. See the full analysis below.

Screen.Recording.2026-02-16.at.17.59.10.mov

Bug

When a push-mode EuiFlyout mounts or unmounts during a React commit that also involves other DOM mutations (portal swaps, tab switching, conditional rendering), the browser throws:

NotFoundError: Failed to execute 'insertBefore' on 'Node':
The node before which the new node is to be inserted is not a child of this node.

The stack trace points to @emotion/sheet_insertTagcontainer.insertBefore(tag, before).

Changing type="push" to type="overlay" completely eliminates the error.

When it does NOT happen

  • Push flyouts that mount/unmount in isolation (e.g., user clicks a button, flyout opens — no other DOM work in the same commit) work fine.
  • Overlay-mode flyouts never trigger this, regardless of how complex the surrounding DOM operations are.
  • In Kibana, the Discover document viewer flyout also uses push mode but does not trigger this error, because it mounts in response to a user action without concurrent portal DOM operations.

When it DOES happen

The error requires all of these conditions simultaneously:

  1. EuiFlyout with type="push" (and viewport >= pushMinBreakpoint so isPushed is true)
  2. The flyout mounts or unmounts during a React commit where other components are also performing DOM mutations, for example, react-reverse-portal doing replaceChild to swap tab content

How we found this

In PR #251637, we are adding the ability to persist the Metric Insights flyout state across Discover tabs. The PR introduces useRestorableState for flyoutState, so when a user duplicates a tab that has the flyout open, the new tab restores the flyout state and the push-mode EuiFlyout is visible in the duplicated tab.


Analysis

We believe the problem is that EuiFlyout calls setGlobalCSSVariables inside a useLayoutEffect (synchronous), which is unique to push-mode flyouts. All other EUI components that call setGlobalCSSVariables use useEffect (asynchronous).

Step by step

1. Push flyout mounts or unmounts -> useLayoutEffect calls setGlobalCSSVariables

The useLayoutEffect in flyout.component.tsx runs synchronously during React's commit phase, both its body (on mount) and its cleanup (on unmount):

useLayoutEffect(() => {
  if (!isPushed) return;  // <- overlay exits here, push continues

  document.body.style[paddingSide] = `${paddingWidth}px`;
  setGlobalCSSVariables({ [cssVarName]: `${paddingWidth}px` });  // <- TRIGGERS ON MOUNT

  return () => {
    document.body.style[paddingSide] = '';
    setGlobalCSSVariables({ [cssVarName]: null });  // <- TRIGGERS ON UNMOUNT
  };
}, [...]);

2. setGlobalCSSVariables forces a synchronous re-render of EuiThemeProvider

setGlobalCSSVariables calls setState on themeCSSVariables in EuiThemeProvider. Because it's called from a useLayoutEffect, React processes this state update synchronously within the current commit — it creates a nested commit.

3. The <Global> component re-renders inside the nested commit

EuiThemeProvider conditionally renders a <Global> component when themeCSSVariables is truthy:

{isGlobalTheme && themeCSSVariables && (
  <Global styles={{ ':root': themeCSSVariables }} />
)}

When themeCSSVariables changes, this <Global> re-renders. Its useInsertionEffect flushes old style tags and inserts new ones.

Why type="overlay" doesn't trigger this

Line 366-367 of flyout.component.tsx:

if (!isPushed) {
  return; // Only push-type flyouts manage body padding
}

With type="overlay", isPushed is false. The useLayoutEffect returns immediately. setGlobalCSSVariables is never called. No synchronous state update, no nested commit, no <Global> re-render, no crash.


Secondary issue(Could be related?): _flyout_overlay.tsx uses standalone @emotion/css

_flyout_overlay.tsx imports from the standalone @emotion/css package:

import { css, cx } from '@emotion/css';

This creates a separate Emotion cache with key: 'css' — the same key that Kibana uses for its main Emotion cache (configured in eui_provider.tsx: createCache({ key: 'css', container: meta[name="emotion"] })). Emotion explicitly warns about this:

"Please make sure it's unique (and not equal to 'css') as it's used for linking styles to your cache. If multiple caches share the same key they might 'fight' for each other's style elements."

The css() call runs on every render of EuiFlyoutOverlay, even for push-mode flyouts where the overlay mask is never rendered.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions