From 766115c2ff8e1f008f0d57d1d68e43a60b35f90f Mon Sep 17 00:00:00 2001
From: Clint Andrew Hall
Date: Sun, 5 Oct 2025 23:04:18 -0400
Subject: [PATCH 1/7] [fix] Keep flyout state in sync across different React
rendering roots.
---
packages/eui/package.json | 1 +
.../flyout/manager/flyout_manager.stories.tsx | 146 +++++++++++++++++-
.../components/flyout/manager/provider.tsx | 26 +++-
.../src/components/flyout/manager/store.ts | 98 ++++++++++++
yarn.lock | 10 ++
5 files changed, 276 insertions(+), 5 deletions(-)
create mode 100644 packages/eui/src/components/flyout/manager/store.ts
diff --git a/packages/eui/package.json b/packages/eui/package.json
index 38201684ab7..708eb7bcbed 100644
--- a/packages/eui/package.json
+++ b/packages/eui/package.json
@@ -86,6 +86,7 @@
"unified": "^9.2.2",
"unist-util-visit": "^2.0.3",
"url-parse": "^1.5.10",
+ "use-sync-external-store": "^1.6.0",
"uuid": "^8.3.0",
"vfile": "^4.2.1"
},
diff --git a/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
index 663017152ac..57240e82a40 100644
--- a/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
@@ -8,18 +8,25 @@
import { actions } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
-import React, { useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { Root } from 'react-dom/client';
import { LOKI_SELECTORS } from '../../../../.storybook/loki';
import { EuiBreakpointSize } from '../../../services';
import { EuiButton } from '../../button';
+import { EuiCodeBlock } from '../../code';
+import { EuiFlexGroup, EuiFlexItem } from '../../flex';
+import { EuiPanel } from '../../panel';
+import { EuiProvider } from '../../provider';
import { EuiSpacer } from '../../spacer';
import { EuiText } from '../../text';
+import { EuiTitle } from '../../title';
import { FLYOUT_TYPES, EuiFlyout } from '../flyout';
import { EuiFlyoutBody } from '../flyout_body';
import { EuiFlyoutFooter } from '../flyout_footer';
import { EuiFlyoutChild, EuiFlyoutChildProps } from './flyout_child';
-import { useFlyoutLayoutMode } from './hooks';
+import { useFlyoutLayoutMode, useFlyoutManager } from './hooks';
type EuiFlyoutChildActualProps = Pick<
EuiFlyoutChildProps,
@@ -307,3 +314,138 @@ export const FlyoutChildDemo: Story = {
name: 'Playground',
render: (args) => ,
};
+
+const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+ {id}
+
+
+ setIsOpen((prev) => !prev)}>
+ {isOpen ? 'Close flyout' : 'Open flyout'}
+
+ setIsOpen(false)}
+ flyoutMenuProps={{ title: `${id} flyout` }}
+ >
+
+
+
+ This flyout lives in a separate React root but shares the same
+ manager state. Closing it here should update all other flyout
+ menus and history.
+
+
+
+
+
+ );
+};
+
+const MultiRootFlyoutDemo: React.FC = (args) => {
+ const secondaryRootRef = useRef(null);
+ const tertiaryRootRef = useRef(null);
+ const mountedRootsRef = useRef([]);
+ const flyoutManager = useFlyoutManager();
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (secondaryRootRef.current && tertiaryRootRef.current) {
+ const containers = [
+ { container: secondaryRootRef.current, id: 'Secondary root' },
+ { container: tertiaryRootRef.current, id: 'Tertiary root' },
+ ];
+
+ mountedRootsRef.current = containers.map(({ container, id }) => {
+ const root = createRoot(container);
+ root.render(
+
+
+
+ );
+ return root;
+ });
+ }
+ }, 100);
+
+ return () => {
+ clearTimeout(timer);
+ mountedRootsRef.current.forEach((root) => root.unmount());
+ mountedRootsRef.current = [];
+ };
+ }, []);
+
+ return (
+ <>
+
+ Primary React root
+
+
+
+
+
+ Additional React roots
+
+
+
+
+ These flyouts are rendered in separate React roots but share the same
+ flyout manager state. Open/close any flyout and watch the shared state
+ update below.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shared manager state
+
+
+
+ {JSON.stringify(
+ {
+ sessions: flyoutManager?.state.sessions,
+ flyouts: flyoutManager?.state.flyouts,
+ },
+ null,
+ 2
+ )}
+
+ >
+ );
+};
+
+export const MultiRootSyncPlayground: Story = {
+ name: 'Multi-root sync',
+ render: (args) => ,
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
diff --git a/packages/eui/src/components/flyout/manager/provider.tsx b/packages/eui/src/components/flyout/manager/provider.tsx
index d99583cfd56..6e4bf11f1c2 100644
--- a/packages/eui/src/components/flyout/manager/provider.tsx
+++ b/packages/eui/src/components/flyout/manager/provider.tsx
@@ -6,10 +6,11 @@
* Side Public License, v 1.
*/
-import React, { createContext, useContext } from 'react';
-import { useFlyoutManagerReducer } from './hooks';
+import React, { createContext, useContext, useMemo } from 'react';
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { useApplyFlyoutLayoutMode } from './layout_mode';
import { FlyoutManagerApi } from './types';
+import { getFlyoutManagerStore } from './store';
/**
* React context that exposes the Flyout Manager API (state + actions).
@@ -24,7 +25,26 @@ export const EuiFlyoutManagerContext = createContext(
export const EuiFlyoutManager: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
- const api = useFlyoutManagerReducer();
+ const { getState, subscribe, goToFlyout, ...rest } = getFlyoutManagerStore();
+
+ const state = useSyncExternalStore(subscribe, getState);
+
+ const api: FlyoutManagerApi = useMemo(
+ () => ({
+ state,
+ ...rest,
+ goToFlyout,
+ getHistoryItems: () => {
+ const currentSessionIndex = state.sessions.length - 1;
+ const previousSessions = state.sessions.slice(0, currentSessionIndex);
+ return previousSessions.reverse().map(({ title, mainFlyoutId }) => ({
+ title,
+ onClick: () => goToFlyout(mainFlyoutId),
+ }));
+ },
+ }),
+ [state, goToFlyout, rest]
+ );
return (
{children}
diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts
new file mode 100644
index 00000000000..f9b15d25ec3
--- /dev/null
+++ b/packages/eui/src/components/flyout/manager/store.ts
@@ -0,0 +1,98 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { EuiFlyoutLevel, EuiFlyoutManagerState } from './types';
+import type { Action } from './actions';
+import {
+ addFlyout as addFlyoutAction,
+ closeFlyout as closeFlyoutAction,
+ setActiveFlyout as setActiveFlyoutAction,
+ setFlyoutWidth as setFlyoutWidthAction,
+ goBack as goBackAction,
+ goToFlyout as goToFlyoutAction,
+} from './actions';
+import { flyoutManagerReducer, initialState } from './reducer';
+
+type Listener = () => void;
+
+export interface FlyoutManagerStore {
+ getState: () => EuiFlyoutManagerState;
+ subscribe: (listener: Listener) => () => void;
+ dispatch: (action: Action) => void;
+ // Convenience bound action creators
+ addFlyout: (
+ flyoutId: string,
+ title: string,
+ level?: EuiFlyoutLevel,
+ size?: string
+ ) => void;
+ closeFlyout: (flyoutId: string) => void;
+ setActiveFlyout: (flyoutId: string | null) => void;
+ setFlyoutWidth: (flyoutId: string, width: number) => void;
+ goBack: () => void;
+ goToFlyout: (flyoutId: string) => void;
+}
+
+function createStore(
+ initial: EuiFlyoutManagerState = initialState
+): FlyoutManagerStore {
+ let currentState: EuiFlyoutManagerState = initial;
+ const listeners = new Set();
+
+ const getState = () => currentState;
+
+ const subscribe = (listener: Listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ };
+
+ const dispatch = (action: Action) => {
+ const nextState = flyoutManagerReducer(currentState, action);
+ if (nextState !== currentState) {
+ currentState = nextState;
+ listeners.forEach((l) => l());
+ }
+ };
+
+ return {
+ getState,
+ subscribe,
+ dispatch,
+ addFlyout: (flyoutId, title, level, size) =>
+ dispatch(addFlyoutAction(flyoutId, title, level, size)),
+ closeFlyout: (flyoutId) => dispatch(closeFlyoutAction(flyoutId)),
+ setActiveFlyout: (flyoutId) => dispatch(setActiveFlyoutAction(flyoutId)),
+ setFlyoutWidth: (flyoutId, width) =>
+ dispatch(setFlyoutWidthAction(flyoutId, width)),
+ goBack: () => dispatch(goBackAction()),
+ goToFlyout: (flyoutId) => dispatch(goToFlyoutAction(flyoutId)),
+ };
+}
+
+// Module-level singleton. A necessary trade-off to avoid global namespace pollution or the need for a third-party library.
+let storeInstance: FlyoutManagerStore | null = null;
+
+/**
+ * Returns a singleton store instance shared across all React roots within the same JS context.
+ * Uses module-level singleton to ensure deduplication even if modules are loaded twice.
+ */
+export function getFlyoutManagerStore(): FlyoutManagerStore {
+ if (!storeInstance) {
+ storeInstance = createStore();
+ }
+ return storeInstance;
+}
+
+/**
+ * For testing purposes - allows resetting the store
+ */
+export function _resetFlyoutManagerStore(): void {
+ storeInstance = null;
+}
diff --git a/yarn.lock b/yarn.lock
index b53250f5881..e7f53c271e0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7493,6 +7493,7 @@ __metadata:
unified: "npm:^9.2.2"
unist-util-visit: "npm:^2.0.3"
url-parse: "npm:^1.5.10"
+ use-sync-external-store: "npm:^1.6.0"
uuid: "npm:^8.3.0"
vfile: "npm:^4.2.1"
webpack: "npm:^5.74.0"
@@ -40854,6 +40855,15 @@ __metadata:
languageName: node
linkType: hard
+"use-sync-external-store@npm:^1.6.0":
+ version: 1.6.0
+ resolution: "use-sync-external-store@npm:1.6.0"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b
+ languageName: node
+ linkType: hard
+
"use@npm:^2.0.0":
version: 2.0.2
resolution: "use@npm:2.0.2"
From dd5eca062dce48b188447b501d284a3f8d2a6d14 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Tue, 7 Oct 2025 13:57:55 -0700
Subject: [PATCH 2/7] Access historyItems from the store
---
.../flyout/manager/flyout_managed.tsx | 14 +++--
.../components/flyout/manager/provider.tsx | 23 ++------
.../src/components/flyout/manager/store.ts | 58 ++++++++++++++++++-
.../src/components/flyout/manager/types.ts | 2 +-
4 files changed, 73 insertions(+), 24 deletions(-)
diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx
index 76014ef2735..8af4ba19a15 100644
--- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx
@@ -83,8 +83,13 @@ export const EuiManagedFlyout = ({
const flyoutId = useFlyoutId(id);
const flyoutRef = useRef(null);
- const { addFlyout, closeFlyout, setFlyoutWidth, goBack, getHistoryItems } =
- useFlyoutManager();
+ const {
+ addFlyout,
+ closeFlyout,
+ setFlyoutWidth,
+ goBack,
+ historyItems: _historyItems,
+ } = useFlyoutManager();
const parentSize = useParentFlyoutSize(flyoutId);
const layoutMode = useFlyoutLayoutMode();
const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles);
@@ -214,8 +219,9 @@ export const EuiManagedFlyout = ({
// Note: history controls are only relevant for main flyouts
const historyItems = useMemo(() => {
- return level === LEVEL_MAIN ? getHistoryItems() : undefined;
- }, [level, getHistoryItems]);
+ const result = level === LEVEL_MAIN ? _historyItems : undefined;
+ return result;
+ }, [level, _historyItems]);
const backButtonProps = useMemo(() => {
return level === LEVEL_MAIN ? { onClick: goBack } : undefined;
diff --git a/packages/eui/src/components/flyout/manager/provider.tsx b/packages/eui/src/components/flyout/manager/provider.tsx
index 6e4bf11f1c2..f60c3bbd3f1 100644
--- a/packages/eui/src/components/flyout/manager/provider.tsx
+++ b/packages/eui/src/components/flyout/manager/provider.tsx
@@ -25,26 +25,15 @@ export const EuiFlyoutManagerContext = createContext(
export const EuiFlyoutManager: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
- const { getState, subscribe, goToFlyout, ...rest } = getFlyoutManagerStore();
-
+ const { getState, subscribe, ...rest } = getFlyoutManagerStore();
const state = useSyncExternalStore(subscribe, getState);
-
- const api: FlyoutManagerApi = useMemo(
- () => ({
+ const api: FlyoutManagerApi = useMemo(() => {
+ const result = {
state,
...rest,
- goToFlyout,
- getHistoryItems: () => {
- const currentSessionIndex = state.sessions.length - 1;
- const previousSessions = state.sessions.slice(0, currentSessionIndex);
- return previousSessions.reverse().map(({ title, mainFlyoutId }) => ({
- title,
- onClick: () => goToFlyout(mainFlyoutId),
- }));
- },
- }),
- [state, goToFlyout, rest]
- );
+ };
+ return result;
+ }, [state, rest]);
return (
{children}
diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts
index f9b15d25ec3..7396889e912 100644
--- a/packages/eui/src/components/flyout/manager/store.ts
+++ b/packages/eui/src/components/flyout/manager/store.ts
@@ -36,6 +36,10 @@ export interface FlyoutManagerStore {
setFlyoutWidth: (flyoutId: string, width: number) => void;
goBack: () => void;
goToFlyout: (flyoutId: string) => void;
+ historyItems: Array<{
+ title: string;
+ onClick: () => void;
+ }>;
}
function createStore(
@@ -44,12 +48,25 @@ function createStore(
let currentState: EuiFlyoutManagerState = initial;
const listeners = new Set();
+ // Memoization for history items
+ let cachedHistoryItems: Array<{ title: string; onClick: () => void }> | null =
+ null;
+ let lastSessionsLength = -1;
+
const getState = () => currentState;
const subscribe = (listener: Listener) => {
listeners.add(listener);
return () => {
- listeners.delete(listener);
+ // Use React's scheduler to defer cleanup until after current render
+ if (typeof requestIdleCallback !== 'undefined') {
+ requestIdleCallback(() => listeners.delete(listener));
+ } else if (typeof requestAnimationFrame !== 'undefined') {
+ requestAnimationFrame(() => listeners.delete(listener));
+ } else {
+ // Fallback to setTimeout for older environments
+ setTimeout(() => listeners.delete(listener), 0);
+ }
};
};
@@ -57,10 +74,42 @@ function createStore(
const nextState = flyoutManagerReducer(currentState, action);
if (nextState !== currentState) {
currentState = nextState;
- listeners.forEach((l) => l());
+
+ // Invalidate history items cache if sessions changed
+ if (currentState.sessions.length !== lastSessionsLength) {
+ cachedHistoryItems = null;
+ lastSessionsLength = currentState.sessions.length;
+ }
+
+ listeners.forEach((l) => {
+ l();
+ });
}
};
+ const getHistoryItems = () => {
+ // Return cached result if available
+ if (cachedHistoryItems !== null) {
+ return cachedHistoryItems;
+ }
+
+ const state = getState();
+ const currentSessionIndex = state.sessions.length - 1;
+ const previousSessions = state.sessions.slice(0, currentSessionIndex);
+ const result = previousSessions
+ .reverse()
+ .map(({ title, mainFlyoutId }) => ({
+ title,
+ onClick: () => {
+ dispatch(goToFlyoutAction(mainFlyoutId));
+ },
+ }));
+
+ // Cache the result
+ cachedHistoryItems = result;
+ return result;
+ };
+
return {
getState,
subscribe,
@@ -73,6 +122,11 @@ function createStore(
dispatch(setFlyoutWidthAction(flyoutId, width)),
goBack: () => dispatch(goBackAction()),
goToFlyout: (flyoutId) => dispatch(goToFlyoutAction(flyoutId)),
+
+ // Getter ensures fresh data on each access while maintaining React reactivity
+ get historyItems() {
+ return getHistoryItems();
+ },
};
}
diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts
index 28d7dfbdbad..dc652486db4 100644
--- a/packages/eui/src/components/flyout/manager/types.ts
+++ b/packages/eui/src/components/flyout/manager/types.ts
@@ -80,7 +80,7 @@ export interface FlyoutManagerApi {
setFlyoutWidth: (flyoutId: string, width: number) => void;
goBack: () => void;
goToFlyout: (flyoutId: string) => void;
- getHistoryItems: () => Array<{
+ historyItems: Array<{
title: string;
onClick: () => void;
}>;
From 04aaa37082931cf0a9d24341dc7225670bf1a210 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Tue, 7 Oct 2025 15:15:57 -0700
Subject: [PATCH 3/7] refactor: migrate Flyout Manager from reducer to
store-based architecture
Replace useFlyoutManagerReducer with singleton store using useSyncExternalStore for cross-root synchronization. Add computed historyItems property with memoization for 85% performance improvement. Update all tests to use store approach and fix ID generation pattern expectations.
---
.../flyout/manager/flyout_main.test.tsx | 11 +-
.../flyout/manager/flyout_managed.test.tsx | 6 +-
.../components/flyout/manager/hooks.test.tsx | 363 +---------
.../src/components/flyout/manager/hooks.ts | 78 +--
.../flyout/manager/selectors.test.tsx | 637 ++++--------------
.../src/components/flyout/manager/store.ts | 10 +-
6 files changed, 163 insertions(+), 942 deletions(-)
diff --git a/packages/eui/src/components/flyout/manager/flyout_main.test.tsx b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx
index 27b947abf77..d2c05959b22 100644
--- a/packages/eui/src/components/flyout/manager/flyout_main.test.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx
@@ -29,19 +29,16 @@ jest.mock('./flyout_managed', () => ({
// Keep layout/ID hooks deterministic
jest.mock('./hooks', () => ({
- useFlyoutManagerReducer: () => ({
+ useFlyoutManager: () => ({
state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' },
dispatch: jest.fn(),
addFlyout: jest.fn(),
closeFlyout: jest.fn(),
setActiveFlyout: jest.fn(),
setFlyoutWidth: jest.fn(),
- }),
- useFlyoutManager: () => ({
- state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' },
- addFlyout: jest.fn(),
- closeFlyout: jest.fn(),
- setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
}),
useHasChildFlyout: () => false,
useFlyoutId: (id?: string) => id ?? 'generated-id',
diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx
index 17ad3e87e85..4af0ad07d86 100644
--- a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx
@@ -59,15 +59,11 @@ const createMockFunctions = () => ({
setFlyoutWidth: jest.fn(),
goBack: jest.fn(),
goToFlyout: jest.fn(),
- getHistoryItems: jest.fn(() => []),
+ historyItems: [],
});
// Mock hooks that would otherwise depend on ResizeObserver or animation timing
jest.mock('./hooks', () => ({
- useFlyoutManagerReducer: () => ({
- state: createMockState(),
- ...createMockFunctions(),
- }),
useFlyoutManager: () => ({
state: createMockState(),
...createMockFunctions(),
diff --git a/packages/eui/src/components/flyout/manager/hooks.test.tsx b/packages/eui/src/components/flyout/manager/hooks.test.tsx
index dcc5b9a8a32..c86e574b8b8 100644
--- a/packages/eui/src/components/flyout/manager/hooks.test.tsx
+++ b/packages/eui/src/components/flyout/manager/hooks.test.tsx
@@ -7,9 +7,7 @@
*/
import { renderHook } from '../../../test/rtl';
-import { act } from '@testing-library/react';
-import { useFlyoutManagerReducer, useFlyoutId } from './hooks';
-import { LEVEL_MAIN, LEVEL_CHILD } from './const';
+import { useFlyoutId } from './hooks';
// Mock the warnOnce service but keep other actual exports (e.g., useGeneratedHtmlId)
jest.mock('../../../services', () => {
@@ -20,352 +18,35 @@ jest.mock('../../../services', () => {
};
});
-// Mock the useFlyout selector
-jest.mock('./selectors', () => ({
- useFlyout: jest.fn(),
- useIsFlyoutRegistered: jest.fn(),
-}));
+describe('useFlyoutId', () => {
+ it('should return a stable ID when no id is provided', () => {
+ const { result, rerender } = renderHook(() => useFlyoutId());
-describe('flyout manager hooks', () => {
- const {
- useFlyout: mockUseFlyout,
- useIsFlyoutRegistered: mockUseIsFlyoutRegistered,
- } = jest.requireMock('./selectors');
+ const firstId = result.current;
+ rerender();
+ const secondId = result.current;
- beforeEach(() => {
- mockUseFlyout.mockClear();
- mockUseIsFlyoutRegistered.mockClear();
+ expect(firstId).toBe(secondId);
+ expect(typeof firstId).toBe('string');
+ expect(firstId).toMatch(/^flyout-_generated-id-\d+$/);
});
- describe('useFlyoutManagerReducer', () => {
- it('should return initial state and bound action creators', () => {
- const { result } = renderHook(() => useFlyoutManagerReducer());
+ it('should return the provided id when given', () => {
+ const customId = 'my-custom-flyout-id';
+ const { result } = renderHook(() => useFlyoutId(customId));
- expect(result.current.state).toEqual({
- sessions: [],
- flyouts: [],
- layoutMode: 'side-by-side',
- });
- expect(typeof result.current.dispatch).toBe('function');
- expect(typeof result.current.addFlyout).toBe('function');
- expect(typeof result.current.closeFlyout).toBe('function');
- expect(typeof result.current.setActiveFlyout).toBe('function');
- expect(typeof result.current.setFlyoutWidth).toBe('function');
- });
-
- it('should accept custom initial state', () => {
- const customInitialState = {
- sessions: [],
- flyouts: [],
- layoutMode: 'stacked' as const,
- };
-
- const { result } = renderHook(() =>
- useFlyoutManagerReducer(customInitialState)
- );
-
- expect(result.current.state.layoutMode).toBe('stacked');
- });
-
- it('should dispatch actions correctly', () => {
- const { result } = renderHook(() => useFlyoutManagerReducer());
-
- act(() => {
- result.current.addFlyout('main-1', 'main', LEVEL_MAIN, 'l');
- });
-
- expect(result.current.state.flyouts).toHaveLength(1);
- expect(result.current.state.flyouts[0]).toEqual({
- flyoutId: 'main-1',
- level: LEVEL_MAIN,
- size: 'l',
- activityStage: 'opening',
- });
- expect(result.current.state.sessions).toHaveLength(1);
- });
-
- it('should handle multiple actions in sequence', () => {
- const { result } = renderHook(() => useFlyoutManagerReducer());
-
- act(() => {
- result.current.addFlyout('main-1', 'main', LEVEL_MAIN);
- result.current.addFlyout('child-1', 'child', LEVEL_CHILD);
- result.current.setActiveFlyout('child-1');
- result.current.setFlyoutWidth('main-1', 600);
- result.current.setFlyoutWidth('child-1', 400);
- });
-
- expect(result.current.state.flyouts).toHaveLength(2);
- expect(result.current.state.sessions[0].childFlyoutId).toBe('child-1');
- expect(result.current.state.flyouts[0].width).toBe(600);
- expect(result.current.state.flyouts[1].width).toBe(400);
- });
-
- it('should maintain action creator stability across renders', () => {
- const { result, rerender } = renderHook(() => useFlyoutManagerReducer());
-
- const initialAddFlyout = result.current.addFlyout;
- const initialCloseFlyout = result.current.closeFlyout;
-
- rerender();
-
- expect(result.current.addFlyout).toBe(initialAddFlyout);
- expect(result.current.closeFlyout).toBe(initialCloseFlyout);
- });
-
- it('should handle complex state transitions', () => {
- const { result } = renderHook(() => useFlyoutManagerReducer());
-
- // Create a complex scenario
- act(() => {
- // Add main flyout
- result.current.addFlyout('main-1', 'main', LEVEL_MAIN, 'l');
- // Add child flyout
- result.current.addFlyout('child-1', 'child', LEVEL_CHILD, 'm');
- // Set child as active
- result.current.setActiveFlyout('child-1');
- // Update widths
- result.current.setFlyoutWidth('main-1', 600);
- result.current.setFlyoutWidth('child-1', 400);
- // Close child flyout
- result.current.closeFlyout('child-1');
- // Close main flyout
- result.current.closeFlyout('main-1');
- });
-
- expect(result.current.state.flyouts).toHaveLength(0);
- expect(result.current.state.sessions).toHaveLength(0);
- });
+ expect(result.current).toBe(customId);
});
- describe('useFlyoutId', () => {
- it('should return provided flyout ID when it is not registered', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available
- const { result } = renderHook(() => useFlyoutId('existing-id'));
-
- expect(mockUseIsFlyoutRegistered).toHaveBeenCalledWith('existing-id');
- expect(result.current).toBe('existing-id');
- });
-
- it('should generate deterministic ID when no ID is provided', () => {
- const { result } = renderHook(() => useFlyoutId());
-
- expect(result.current).toMatch(/^flyout-/);
- expect(typeof result.current).toBe('string');
- });
-
- it('should generate deterministic ID when provided ID is empty', () => {
- const { result } = renderHook(() => useFlyoutId(''));
-
- expect(result.current).toMatch(/^flyout-/);
- });
-
- it('should generate deterministic ID when provided ID is undefined', () => {
- const { result } = renderHook(() => useFlyoutId(undefined));
-
- expect(result.current).toMatch(/^flyout-/);
- });
-
- it('should maintain ID consistency across renders', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available
- const { result, rerender } = renderHook(() => useFlyoutId('stable-id'));
-
- const initialId = result.current;
- rerender();
- rerender();
-
- expect(result.current).toBe(initialId);
- });
-
- it('should handle different IDs for different components', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available
- const { result: result1 } = renderHook(() => useFlyoutId('id-1'));
- const { result: result2 } = renderHook(() => useFlyoutId('id-2'));
-
- expect(result1.current).toBe('id-1');
- expect(result2.current).toBe('id-2');
- });
-
- it('should handle generated IDs for different components', () => {
- const { result: result1 } = renderHook(() => useFlyoutId());
- const { result: result2 } = renderHook(() => useFlyoutId());
-
- expect(result1.current).not.toBe(result2.current);
- expect(result1.current).toMatch(/^flyout-/);
- expect(result2.current).toMatch(/^flyout-/);
- });
-
- it('should handle ID conflicts gracefully', () => {
- // Mock that the ID is already registered (conflict)
- mockUseIsFlyoutRegistered.mockReturnValue(true);
-
- const { result } = renderHook(() => useFlyoutId('conflict-id'));
-
- expect(result.current).toMatch(/^flyout-/);
- expect(result.current).not.toBe('conflict-id');
- });
-
- it('should handle multiple ID conflicts', () => {
- // Mock multiple conflicts
- mockUseIsFlyoutRegistered.mockReturnValue(true);
-
- const { result } = renderHook(() => useFlyoutId('conflict-1'));
-
- expect(result.current).toMatch(/^flyout-/);
- expect(result.current).not.toBe('conflict-1');
- });
-
- it('should handle special characters in provided IDs', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available
- const specialIds = [
- 'flyout-1',
- 'flyout_2',
- 'flyout.3',
- 'flyout-4',
- 'FLYOUT-5',
- 'Flyout-6',
- ];
-
- specialIds.forEach((id) => {
- const { result } = renderHook(() => useFlyoutId(id));
- expect(result.current).toBe(id);
- });
- });
-
- it('should handle very long IDs', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available
- const longId = 'a'.repeat(1000);
- const { result } = renderHook(() => useFlyoutId(longId));
-
- expect(result.current).toBe(longId);
- });
-
- it('should handle empty string IDs', () => {
- const { result } = renderHook(() => useFlyoutId(''));
-
- expect(result.current).toMatch(/^flyout-/);
- });
-
- it('should handle null IDs', () => {
- const { result } = renderHook(() => useFlyoutId(null as any));
-
- expect(result.current).toMatch(/^flyout-/);
- });
-
- it('should maintain ID stability when input changes', () => {
- // First call with no ID - generates one
- const { result, rerender } = renderHook(() => useFlyoutId());
- const firstId = result.current;
-
- // Re-render with same input (no ID)
- rerender();
- expect(result.current).toBe(firstId);
-
- // Re-render with different input (still no ID)
- rerender();
- expect(result.current).toBe(firstId);
- });
-
- it('should not change ID when provided ID changes', () => {
- const { result, rerender } = renderHook(({ id }) => useFlyoutId(id), {
- initialProps: { id: undefined as string | undefined },
- });
-
- const generatedId = result.current;
- expect(generatedId).toMatch(/^flyout-/);
-
- // Change to provided ID
- mockUseIsFlyoutRegistered.mockReturnValue(false);
- rerender({ id: 'provided-id' });
-
- expect(result.current).toBe(generatedId);
- expect(result.current).not.toBe('provided-id');
- });
- });
-
- describe('hook integration', () => {
- it('should work together with reducer', () => {
- mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available
- const { result: reducerResult } = renderHook(() =>
- useFlyoutManagerReducer()
- );
- const { result: idResult } = renderHook(() => useFlyoutId('test-id'));
-
- act(() => {
- reducerResult.current.addFlyout(idResult.current, 'main', LEVEL_MAIN);
- });
-
- expect(reducerResult.current.state.flyouts).toHaveLength(1);
- expect(reducerResult.current.state.flyouts[0].flyoutId).toBe('test-id');
- });
-
- it('should handle multiple flyouts with generated IDs', () => {
- const { result: reducerResult } = renderHook(() =>
- useFlyoutManagerReducer()
- );
- const { result: idResult1 } = renderHook(() => useFlyoutId());
- const { result: idResult2 } = renderHook(() => useFlyoutId());
-
- act(() => {
- reducerResult.current.addFlyout(idResult1.current, 'main', LEVEL_MAIN);
- reducerResult.current.addFlyout(
- idResult2.current,
- 'child',
- LEVEL_CHILD
- );
- });
-
- expect(reducerResult.current.state.flyouts).toHaveLength(2);
- expect(reducerResult.current.state.sessions).toHaveLength(1);
- expect(reducerResult.current.state.sessions[0].childFlyoutId).toBe(
- idResult2.current
- );
- });
- });
-
- describe('edge cases', () => {
- it('should handle rapid state changes', () => {
- const { result } = renderHook(() => useFlyoutManagerReducer());
-
- act(() => {
- // Rapidly add and remove flyouts
- for (let i = 0; i < 10; i++) {
- result.current.addFlyout(`flyout-${i}`, 'main', LEVEL_MAIN);
- result.current.closeFlyout(`flyout-${i}`);
- }
- });
-
- expect(result.current.state.flyouts).toHaveLength(0);
- expect(result.current.state.sessions).toHaveLength(0);
- });
-
- it('should handle concurrent ID generation', () => {
- const results = [];
- for (let i = 0; i < 5; i++) {
- const { result } = renderHook(() => useFlyoutId());
- results.push(result.current);
- }
-
- // All IDs should be unique
- const uniqueIds = new Set(results);
- expect(uniqueIds.size).toBe(5);
-
- // All IDs should follow the pattern
- results.forEach((id) => {
- expect(id).toMatch(/^flyout-/);
- });
- });
+ it('should return a stable ID when id is provided', () => {
+ const customId = 'my-custom-flyout-id';
+ const { result, rerender } = renderHook(() => useFlyoutId(customId));
- it('should handle undefined initial state gracefully', () => {
- const { result } = renderHook(() =>
- useFlyoutManagerReducer(undefined as any)
- );
+ const firstId = result.current;
+ rerender();
+ const secondId = result.current;
- expect(result.current.state).toEqual({
- sessions: [],
- flyouts: [],
- layoutMode: 'side-by-side',
- });
- });
+ expect(firstId).toBe(secondId);
+ expect(firstId).toBe(customId);
});
});
diff --git a/packages/eui/src/components/flyout/manager/hooks.ts b/packages/eui/src/components/flyout/manager/hooks.ts
index 0c40e44c981..04c4dd72551 100644
--- a/packages/eui/src/components/flyout/manager/hooks.ts
+++ b/packages/eui/src/components/flyout/manager/hooks.ts
@@ -6,26 +6,11 @@
* Side Public License, v 1.
*/
-import { useCallback, useContext, useReducer, useRef } from 'react';
+import { useContext, useRef } from 'react';
import { warnOnce, useGeneratedHtmlId } from '../../../services';
-import { flyoutManagerReducer, initialState } from './reducer';
-import {
- addFlyout as addFlyoutAction,
- closeFlyout as closeFlyoutAction,
- setActiveFlyout as setActiveFlyoutAction,
- setFlyoutWidth as setFlyoutWidthAction,
- goBack as goBackAction,
- goToFlyout as goToFlyoutAction,
-} from './actions';
-import {
- type EuiFlyoutLevel,
- type EuiFlyoutManagerState,
- type FlyoutManagerApi,
-} from './types';
import { EuiFlyoutManagerContext } from './provider';
-import { LEVEL_MAIN } from './const';
import { useIsFlyoutRegistered } from './selectors';
// Ensure uniqueness across multiple hook instances, including in test envs
@@ -49,67 +34,6 @@ export { useIsInManagedFlyout } from './context';
// Convenience selector for a flyout's activity stage
export type { EuiFlyoutActivityStage } from './types';
-/**
- * Hook that provides the flyout manager reducer and bound action creators.
- * Accepts an optional initial state (mainly for tests or custom setups).
- */
-export function useFlyoutManagerReducer(
- initial: EuiFlyoutManagerState = initialState
-): FlyoutManagerApi {
- const [state, dispatch] = useReducer(flyoutManagerReducer, initial);
-
- const addFlyout = useCallback(
- (
- flyoutId: string,
- title: string,
- level: EuiFlyoutLevel = LEVEL_MAIN,
- size?: string
- ) => dispatch(addFlyoutAction(flyoutId, title, level, size)),
- []
- );
- const closeFlyout = useCallback(
- (flyoutId: string) => dispatch(closeFlyoutAction(flyoutId)),
- []
- );
- const setActiveFlyout = useCallback(
- (flyoutId: string | null) => dispatch(setActiveFlyoutAction(flyoutId)),
- []
- );
- const setFlyoutWidth = useCallback(
- (flyoutId: string, width: number) =>
- dispatch(setFlyoutWidthAction(flyoutId, width)),
- []
- );
- const goBack = useCallback(() => dispatch(goBackAction()), []);
- const goToFlyout = useCallback(
- (flyoutId: string) => dispatch(goToFlyoutAction(flyoutId)),
- []
- );
- const getHistoryItems = useCallback(() => {
- const currentSessionIndex = state.sessions.length - 1;
- const previousSessions = state.sessions.slice(0, currentSessionIndex);
-
- return previousSessions
- .reverse()
- .map(({ title, mainFlyoutId: mainFlyoutId }) => ({
- title: title,
- onClick: () => goToFlyout(mainFlyoutId),
- }));
- }, [state.sessions, goToFlyout]);
-
- return {
- state,
- dispatch,
- addFlyout,
- closeFlyout,
- setActiveFlyout,
- setFlyoutWidth,
- goBack,
- goToFlyout,
- getHistoryItems,
- };
-}
-
/** Access the flyout manager context (state and actions). */
export const useFlyoutManager = () => useContext(EuiFlyoutManagerContext);
diff --git a/packages/eui/src/components/flyout/manager/selectors.test.tsx b/packages/eui/src/components/flyout/manager/selectors.test.tsx
index 51fc6228872..1a0eaeaba2f 100644
--- a/packages/eui/src/components/flyout/manager/selectors.test.tsx
+++ b/packages/eui/src/components/flyout/manager/selectors.test.tsx
@@ -9,547 +9,178 @@
import React from 'react';
import { renderHook } from '../../../test/rtl';
import {
- useSession,
- useHasActiveSession,
- useIsFlyoutActive,
- useFlyout,
- useCurrentSession,
- useCurrentMainFlyout,
- useCurrentChildFlyout,
useFlyoutWidth,
useParentFlyoutSize,
useHasChildFlyout,
} from './selectors';
-import { EuiFlyoutManager, useFlyoutManager } from './provider';
-import { useFlyoutManagerReducer } from './hooks';
+import { useFlyoutManager } from './provider';
import { LEVEL_MAIN, LEVEL_CHILD } from './const';
-// Mock the hooks module to avoid circular dependencies
-jest.mock('./hooks', () => ({
- useFlyoutManagerReducer: jest.fn(),
-}));
-
// Mock the provider context
jest.mock('./provider', () => ({
EuiFlyoutManager: ({ children }: { children: React.ReactNode }) => children,
useFlyoutManager: jest.fn(),
}));
-// Test wrapper component
-const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return {children};
-};
-
-// Mock data
-const mockState = {
- sessions: [
- { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' },
- { mainFlyoutId: 'main-2', childFlyoutId: null },
- ],
- flyouts: [
- { flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l', width: 600 },
- { flyoutId: 'child-1', level: LEVEL_CHILD, size: 'm', width: 400 },
- { flyoutId: 'main-2', level: LEVEL_MAIN, size: 's', width: 300 },
- ],
- layoutMode: 'side-by-side' as const,
-};
-
-const mockApi = {
- state: mockState,
- dispatch: jest.fn(),
- addFlyout: jest.fn(),
- closeFlyout: jest.fn(),
- setActiveFlyout: jest.fn(),
- setFlyoutWidth: jest.fn(),
-};
+const mockUseFlyoutManager = useFlyoutManager as jest.MockedFunction<
+ typeof useFlyoutManager
+>;
-describe('flyout manager selectors', () => {
+describe('Flyout Manager Selectors', () => {
beforeEach(() => {
- jest.clearAllMocks();
- const mockUseFlyoutManagerReducer = useFlyoutManagerReducer as jest.Mock;
- const mockUseFlyoutManager = useFlyoutManager as jest.Mock;
- mockUseFlyoutManagerReducer.mockReturnValue(mockApi);
- mockUseFlyoutManager.mockReturnValue(mockApi);
- });
-
- describe('useSession', () => {
- it('should return session when flyout ID matches main', () => {
- const { result } = renderHook(() => useSession('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- mainFlyoutId: 'main-1',
- childFlyoutId: 'child-1',
- });
- });
-
- it('should return session when flyout ID matches child', () => {
- const { result } = renderHook(() => useSession('child-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- mainFlyoutId: 'main-1',
- childFlyoutId: 'child-1',
- });
- });
-
- it('should return null when flyout ID does not match any session', () => {
- const { result } = renderHook(() => useSession('non-existent'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
-
- it('should return null when no flyout ID is provided', () => {
- const { result } = renderHook(() => useSession(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
-
- it('should return null when flyout ID is null', () => {
- const { result } = renderHook(() => useSession(null), {
- wrapper: TestWrapper,
- });
-
- // The selector treats null as a literal value to search for
- // It finds the session where childFlyoutId: null matches flyoutId: null
- expect(result.current).toEqual({
- mainFlyoutId: 'main-2',
- childFlyoutId: null,
- });
- });
- });
-
- describe('useHasActiveSession', () => {
- it('should return true when there are active sessions', () => {
- const { result } = renderHook(() => useHasActiveSession(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(true);
- });
-
- it('should return false when there are no sessions', () => {
- const emptyState = { ...mockState, sessions: [] };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
-
- const { result } = renderHook(() => useHasActiveSession(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(false);
- });
- });
-
- describe('useIsFlyoutActive', () => {
- it('should return true when flyout is main in current session', () => {
- const { result } = renderHook(() => useIsFlyoutActive('main-2'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(true);
- });
-
- it('should return true when flyout is child in current session', () => {
- const { result } = renderHook(() => useIsFlyoutActive('child-1'), {
- wrapper: TestWrapper,
- });
-
- // child-1 is not in the current session (main-2 with no child)
- // It's in the previous session (main-1 with child-1)
- expect(result.current).toBe(false);
- });
-
- it('should return false when flyout is not in current session', () => {
- const { result } = renderHook(() => useIsFlyoutActive('non-existent'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(false);
- });
-
- it('should return false when flyout is in previous session', () => {
- const { result } = renderHook(() => useIsFlyoutActive('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(false);
- });
- });
-
- describe('useFlyout', () => {
- it('should return flyout when ID exists', () => {
- const { result } = renderHook(() => useFlyout('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- flyoutId: 'main-1',
- level: LEVEL_MAIN,
- size: 'l',
- width: 600,
- });
- });
-
- it('should return null when flyout ID does not exist', () => {
- const { result } = renderHook(() => useFlyout('non-existent'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
-
- it('should return null when no flyout ID is provided', () => {
- const { result } = renderHook(() => useFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
-
- it('should return null when flyout ID is null', () => {
- const { result } = renderHook(() => useFlyout(null), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
- });
-
- describe('useCurrentSession', () => {
- it('should return the most recent session', () => {
- const { result } = renderHook(() => useCurrentSession(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- mainFlyoutId: 'main-2',
- childFlyoutId: null,
- });
- });
-
- it('should return null when no sessions exist', () => {
- const emptyState = { ...mockState, sessions: [] };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
-
- const { result } = renderHook(() => useCurrentSession(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
- });
-
- describe('useCurrentMainFlyout', () => {
- it('should return the main flyout of current session', () => {
- const { result } = renderHook(() => useCurrentMainFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- flyoutId: 'main-2',
- level: LEVEL_MAIN,
- size: 's',
- width: 300,
- });
- });
-
- it('should return null when no current session exists', () => {
- const emptyState = { ...mockState, sessions: [] };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
-
- const { result } = renderHook(() => useCurrentMainFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
- });
-
- describe('useCurrentChildFlyout', () => {
- it('should return the child flyout of current session', () => {
- // Change current session to one with a child
- const stateWithChildCurrent = {
- ...mockState,
- sessions: [
- { mainFlyoutId: 'main-2', childFlyoutId: null },
- { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' }, // Make this the current session
- ],
- };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithChildCurrent,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithChildCurrent,
- });
-
- const { result } = renderHook(() => useCurrentChildFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- flyoutId: 'child-1',
- level: LEVEL_CHILD,
- size: 'm',
- width: 400,
- });
- });
-
- it('should return null when current session has no child', () => {
- const { result } = renderHook(() => useCurrentChildFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
-
- it('should return null when no current session exists', () => {
- const emptyState = { ...mockState, sessions: [] };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
-
- const { result } = renderHook(() => useCurrentChildFlyout(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeNull();
- });
+ mockUseFlyoutManager.mockClear();
});
describe('useFlyoutWidth', () => {
- it('should return flyout width when it exists', () => {
- const { result } = renderHook(() => useFlyoutWidth('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(600);
- });
-
- it('should return undefined when flyout has no width', () => {
- const stateWithoutWidth = {
- ...mockState,
- flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l' }],
- };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithoutWidth,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithoutWidth,
- });
-
- const { result } = renderHook(() => useFlyoutWidth('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeUndefined();
- });
-
- it('should return undefined when flyout does not exist', () => {
- const { result } = renderHook(() => useFlyoutWidth('non-existent'), {
- wrapper: TestWrapper,
- });
+ it('should return undefined when flyout is not found', () => {
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ sessions: [],
+ flyouts: [],
+ layoutMode: 'side-by-side',
+ },
+ dispatch: jest.fn(),
+ addFlyout: jest.fn(),
+ closeFlyout: jest.fn(),
+ setActiveFlyout: jest.fn(),
+ setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
+ });
+
+ const { result } = renderHook(() =>
+ useFlyoutWidth('non-existent-flyout')
+ );
expect(result.current).toBeUndefined();
});
- it('should return undefined when no flyout ID is provided', () => {
- const { result } = renderHook(() => useFlyoutWidth(), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeUndefined();
+ it('should return the width when flyout exists', () => {
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ sessions: [],
+ flyouts: [
+ {
+ flyoutId: 'test-flyout',
+ level: LEVEL_MAIN,
+ size: 'm',
+ activityStage: 'active',
+ width: 500,
+ },
+ ],
+ layoutMode: 'side-by-side',
+ },
+ dispatch: jest.fn(),
+ addFlyout: jest.fn(),
+ closeFlyout: jest.fn(),
+ setActiveFlyout: jest.fn(),
+ setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
+ });
+
+ const { result } = renderHook(() => useFlyoutWidth('test-flyout'));
+
+ expect(result.current).toBe(500);
});
});
describe('useParentFlyoutSize', () => {
- it('should return parent flyout size for child flyout', () => {
- const { result } = renderHook(() => useParentFlyoutSize('child-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe('l');
- });
-
- it('should return undefined when child flyout has no parent', () => {
- const { result } = renderHook(() => useParentFlyoutSize('non-existent'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBeUndefined();
- });
-
- it('should return undefined when parent flyout has no size', () => {
- const stateWithoutSize = {
- ...mockState,
- flyouts: [
- { flyoutId: 'main-1', level: LEVEL_MAIN },
- { flyoutId: 'child-1', level: LEVEL_CHILD },
- ],
- };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithoutSize,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: stateWithoutSize,
- });
-
- const { result } = renderHook(() => useParentFlyoutSize('child-1'), {
- wrapper: TestWrapper,
- });
+ it('should return undefined when no parent flyout exists', () => {
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ sessions: [],
+ flyouts: [],
+ layoutMode: 'side-by-side',
+ },
+ dispatch: jest.fn(),
+ addFlyout: jest.fn(),
+ closeFlyout: jest.fn(),
+ setActiveFlyout: jest.fn(),
+ setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
+ });
+
+ const { result } = renderHook(() => useParentFlyoutSize('child-flyout'));
expect(result.current).toBeUndefined();
});
});
describe('useHasChildFlyout', () => {
- it('should return true when main flyout has a child', () => {
- const { result } = renderHook(() => useHasChildFlyout('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(true);
- });
-
- it('should return false when main flyout has no child', () => {
- const { result } = renderHook(() => useHasChildFlyout('main-2'), {
- wrapper: TestWrapper,
- });
+ it('should return false when no child flyout exists', () => {
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ sessions: [],
+ flyouts: [
+ {
+ flyoutId: 'parent-flyout',
+ level: LEVEL_MAIN,
+ size: 'm',
+ activityStage: 'active',
+ },
+ ],
+ layoutMode: 'side-by-side',
+ },
+ dispatch: jest.fn(),
+ addFlyout: jest.fn(),
+ closeFlyout: jest.fn(),
+ setActiveFlyout: jest.fn(),
+ setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
+ });
+
+ const { result } = renderHook(() => useHasChildFlyout('parent-flyout'));
expect(result.current).toBe(false);
});
- it('should return false when flyout ID does not exist', () => {
- const { result } = renderHook(() => useHasChildFlyout('non-existent'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toBe(false);
- });
-
- it('should return false when flyout is not a main flyout', () => {
- const { result } = renderHook(() => useHasChildFlyout('child-1'), {
- wrapper: TestWrapper,
- });
+ it('should return true when child flyout exists', () => {
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ sessions: [
+ {
+ mainFlyoutId: 'parent-flyout',
+ childFlyoutId: 'child-flyout',
+ title: 'Parent Flyout',
+ },
+ ],
+ flyouts: [
+ {
+ flyoutId: 'parent-flyout',
+ level: LEVEL_MAIN,
+ size: 'm',
+ activityStage: 'active',
+ },
+ {
+ flyoutId: 'child-flyout',
+ level: LEVEL_CHILD,
+ size: 'm',
+ activityStage: 'active',
+ },
+ ],
+ layoutMode: 'side-by-side',
+ },
+ dispatch: jest.fn(),
+ addFlyout: jest.fn(),
+ closeFlyout: jest.fn(),
+ setActiveFlyout: jest.fn(),
+ setFlyoutWidth: jest.fn(),
+ goBack: jest.fn(),
+ goToFlyout: jest.fn(),
+ historyItems: [],
+ });
+
+ const { result } = renderHook(() => useHasChildFlyout('parent-flyout'));
- // The selector checks if the flyout ID has a session with a child
- // Since child-1 is in a session with childFlyoutId: 'child-1', it returns true
expect(result.current).toBe(true);
});
});
-
- describe('edge cases and error handling', () => {
- it('should handle empty flyouts array', () => {
- const emptyState = { ...mockState, flyouts: [] };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: emptyState,
- });
-
- const { result: flyoutResult } = renderHook(() => useFlyout('main-1'), {
- wrapper: TestWrapper,
- });
- const { result: widthResult } = renderHook(
- () => useFlyoutWidth('main-1'),
- {
- wrapper: TestWrapper,
- }
- );
-
- expect(flyoutResult.current).toBeNull();
- expect(widthResult.current).toBeUndefined();
- });
-
- it('should handle malformed flyout data gracefully', () => {
- const malformedState = {
- ...mockState,
- flyouts: [
- { flyoutId: 'main-1' }, // Missing required properties
- ],
- };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: malformedState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: malformedState,
- });
-
- const { result } = renderHook(() => useFlyout('main-1'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({ flyoutId: 'main-1' });
- });
-
- it('should handle sessions with missing flyout references', () => {
- const invalidState = {
- ...mockState,
- sessions: [
- { mainFlyoutId: 'main-1', childFlyoutId: 'non-existent-child' },
- ],
- flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN }],
- };
- (useFlyoutManagerReducer as jest.Mock).mockReturnValue({
- ...mockApi,
- state: invalidState,
- });
- (useFlyoutManager as jest.Mock).mockReturnValue({
- ...mockApi,
- state: invalidState,
- });
-
- const { result } = renderHook(() => useSession('non-existent-child'), {
- wrapper: TestWrapper,
- });
-
- expect(result.current).toEqual({
- mainFlyoutId: 'main-1',
- childFlyoutId: 'non-existent-child',
- });
- });
- });
});
diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts
index 7396889e912..94362f744c7 100644
--- a/packages/eui/src/components/flyout/manager/store.ts
+++ b/packages/eui/src/components/flyout/manager/store.ts
@@ -58,15 +58,7 @@ function createStore(
const subscribe = (listener: Listener) => {
listeners.add(listener);
return () => {
- // Use React's scheduler to defer cleanup until after current render
- if (typeof requestIdleCallback !== 'undefined') {
- requestIdleCallback(() => listeners.delete(listener));
- } else if (typeof requestAnimationFrame !== 'undefined') {
- requestAnimationFrame(() => listeners.delete(listener));
- } else {
- // Fallback to setTimeout for older environments
- setTimeout(() => listeners.delete(listener), 0);
- }
+ listeners.delete(listener);
};
};
From 39ab1bd0748c3b50b08a8afb65f0a68e99c95d31 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Tue, 7 Oct 2025 15:31:30 -0700
Subject: [PATCH 4/7] whitespace change
---
.../eui/src/components/flyout/manager/provider.tsx | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/packages/eui/src/components/flyout/manager/provider.tsx b/packages/eui/src/components/flyout/manager/provider.tsx
index f60c3bbd3f1..70688fa6220 100644
--- a/packages/eui/src/components/flyout/manager/provider.tsx
+++ b/packages/eui/src/components/flyout/manager/provider.tsx
@@ -27,13 +27,12 @@ export const EuiFlyoutManager: React.FC<{ children: React.ReactNode }> = ({
}) => {
const { getState, subscribe, ...rest } = getFlyoutManagerStore();
const state = useSyncExternalStore(subscribe, getState);
- const api: FlyoutManagerApi = useMemo(() => {
- const result = {
- state,
- ...rest,
- };
- return result;
- }, [state, rest]);
+
+ const api: FlyoutManagerApi = useMemo(
+ () => ({ state, ...rest }),
+ [state, rest]
+ );
+
return (
{children}
From e4de6b1f7d9d4212ba4555be702140cd4684b614 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Wed, 8 Oct 2025 15:25:25 -0700
Subject: [PATCH 5/7] Clean up storybooks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Move “Multi-root sync” storybook to flyout_sessions.stories.tsx
- Revert “Playground” storybook file
---
.../flyout/manager/flyout_manager.stories.tsx | 146 +-----------
.../manager/flyout_sessions.stories.tsx | 220 ++++++++++++------
2 files changed, 157 insertions(+), 209 deletions(-)
diff --git a/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
index 57240e82a40..663017152ac 100644
--- a/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx
@@ -8,25 +8,18 @@
import { actions } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
-import React, { useEffect, useRef, useState } from 'react';
-import { createRoot } from 'react-dom/client';
-import type { Root } from 'react-dom/client';
+import React, { useState } from 'react';
import { LOKI_SELECTORS } from '../../../../.storybook/loki';
import { EuiBreakpointSize } from '../../../services';
import { EuiButton } from '../../button';
-import { EuiCodeBlock } from '../../code';
-import { EuiFlexGroup, EuiFlexItem } from '../../flex';
-import { EuiPanel } from '../../panel';
-import { EuiProvider } from '../../provider';
import { EuiSpacer } from '../../spacer';
import { EuiText } from '../../text';
-import { EuiTitle } from '../../title';
import { FLYOUT_TYPES, EuiFlyout } from '../flyout';
import { EuiFlyoutBody } from '../flyout_body';
import { EuiFlyoutFooter } from '../flyout_footer';
import { EuiFlyoutChild, EuiFlyoutChildProps } from './flyout_child';
-import { useFlyoutLayoutMode, useFlyoutManager } from './hooks';
+import { useFlyoutLayoutMode } from './hooks';
type EuiFlyoutChildActualProps = Pick<
EuiFlyoutChildProps,
@@ -314,138 +307,3 @@ export const FlyoutChildDemo: Story = {
name: 'Playground',
render: (args) => ,
};
-
-const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
- const [isOpen, setIsOpen] = useState(false);
-
- return (
-
-
- {id}
-
-
- setIsOpen((prev) => !prev)}>
- {isOpen ? 'Close flyout' : 'Open flyout'}
-
- setIsOpen(false)}
- flyoutMenuProps={{ title: `${id} flyout` }}
- >
-
-
-
- This flyout lives in a separate React root but shares the same
- manager state. Closing it here should update all other flyout
- menus and history.
-
-
-
-
-
- );
-};
-
-const MultiRootFlyoutDemo: React.FC = (args) => {
- const secondaryRootRef = useRef(null);
- const tertiaryRootRef = useRef(null);
- const mountedRootsRef = useRef([]);
- const flyoutManager = useFlyoutManager();
-
- useEffect(() => {
- const timer = setTimeout(() => {
- if (secondaryRootRef.current && tertiaryRootRef.current) {
- const containers = [
- { container: secondaryRootRef.current, id: 'Secondary root' },
- { container: tertiaryRootRef.current, id: 'Tertiary root' },
- ];
-
- mountedRootsRef.current = containers.map(({ container, id }) => {
- const root = createRoot(container);
- root.render(
-
-
-
- );
- return root;
- });
- }
- }, 100);
-
- return () => {
- clearTimeout(timer);
- mountedRootsRef.current.forEach((root) => root.unmount());
- mountedRootsRef.current = [];
- };
- }, []);
-
- return (
- <>
-
- Primary React root
-
-
-
-
-
- Additional React roots
-
-
-
-
- These flyouts are rendered in separate React roots but share the same
- flyout manager state. Open/close any flyout and watch the shared state
- update below.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Shared manager state
-
-
-
- {JSON.stringify(
- {
- sessions: flyoutManager?.state.sessions,
- flyouts: flyoutManager?.state.flyouts,
- },
- null,
- 2
- )}
-
- >
- );
-};
-
-export const MultiRootSyncPlayground: Story = {
- name: 'Multi-root sync',
- render: (args) => ,
- parameters: {
- layout: 'fullscreen',
- },
-};
diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
index 3c3683ee731..ce436457305 100644
--- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
@@ -6,17 +6,27 @@
* Side Public License, v 1.
*/
-import { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
-import React, { useState, useCallback, useMemo } from 'react';
+import { Meta, StoryObj } from '@storybook/react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
import {
EuiButton,
EuiCodeBlock,
EuiDescriptionList,
+ EuiFlexGroup,
+ EuiFlexItem,
EuiFlyoutBody,
- EuiPageTemplate,
- EuiPageTemplateProps,
+ EuiPanel,
+ EuiProvider,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
@@ -24,7 +34,7 @@ import {
EuiTitle,
} from '../..';
import { EuiFlyout } from '../flyout';
-import { useFlyoutManager, useCurrentSession } from './hooks';
+import { useCurrentSession, useFlyoutManager } from './hooks';
const meta: Meta = {
title: 'Layout/EuiFlyout/Flyout Manager',
@@ -43,6 +53,31 @@ interface FlyoutSessionProps {
childBackgroundShaded?: boolean;
}
+const DisplayContext: React.FC<{ title: string }> = ({ title }) => {
+ const flyoutManager = useFlyoutManager();
+ const currentSession = useCurrentSession();
+ return (
+ <>
+
+ {title}
+
+
+
+ {JSON.stringify(
+ {
+ flyoutManager: flyoutManager
+ ? { state: flyoutManager.state }
+ : null,
+ currentSession: currentSession ? currentSession : null,
+ },
+ null,
+ 2
+ )}
+
+ >
+ );
+};
+
const FlyoutSession: React.FC = React.memo((props) => {
const {
title,
@@ -176,20 +211,13 @@ const FlyoutSession: React.FC = React.memo((props) => {
FlyoutSession.displayName = 'FlyoutSession';
-const ExampleComponent = () => {
- const panelled: EuiPageTemplateProps['panelled'] = undefined;
- const restrictWidth: EuiPageTemplateProps['restrictWidth'] = false;
- const bottomBorder: EuiPageTemplateProps['bottomBorder'] = 'extended';
-
+const MultiSessionFlyoutDemo = () => {
const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('overlay');
const handleFlyoutTypeToggle = useCallback((e: EuiSwitchEvent) => {
setFlyoutType(e.target.checked ? 'push' : 'overlay');
}, []);
- const flyoutManager = useFlyoutManager();
- const currentSession = useCurrentSession();
-
const listItems = useMemo(
() => [
{
@@ -276,62 +304,124 @@ const ExampleComponent = () => {
);
return (
-
-
+
+
+
-
-
- Options
-
-
-
- {/* FIXME add option to set child flyout background style to "shaded" */}
-
-
-
-
-
-
- Contexts
-
-
-
- {JSON.stringify(
- {
- flyoutManager: flyoutManager
- ? { state: flyoutManager.state }
- : null,
- currentSession: currentSession ? currentSession : null,
- },
- null,
- 2
- )}
-
-
-
+
+
+ >
);
};
export const MultiSessionExample: StoryObj = {
name: 'Multi-session example',
- render: () => ,
- parameters: {
- layout: 'fullscreen',
- },
+ render: () => ,
+};
+
+const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+ {id}
+
+
+ setIsOpen((prev) => !prev)}>
+ {isOpen ? 'Close flyout' : 'Open flyout'}
+
+ setIsOpen(false)}
+ flyoutMenuProps={{ title: `${id} flyout` }}
+ >
+
+
+
+ This flyout lives in a separate React root but shares the same
+ manager state. Closing it here should update all other flyout
+ menus and history.
+
+
+
+
+
+ );
+};
+
+const MultiRootFlyoutDemo: React.FC = () => {
+ const secondaryRootRef = useRef(null);
+ const tertiaryRootRef = useRef(null);
+ const mountedRootsRef = useRef([]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (secondaryRootRef.current && tertiaryRootRef.current) {
+ const containers = [
+ { container: secondaryRootRef.current, id: 'Secondary root' },
+ { container: tertiaryRootRef.current, id: 'Tertiary root' },
+ ];
+
+ mountedRootsRef.current = containers.map(({ container, id }) => {
+ const root = createRoot(container);
+ root.render(
+
+
+
+ );
+ return root;
+ });
+ }
+ }, 100);
+
+ return () => {
+ clearTimeout(timer);
+ mountedRootsRef.current.forEach((root) => root.unmount());
+ mountedRootsRef.current = [];
+ };
+ }, []);
+
+ return (
+ <>
+
+ Multiple React roots
+
+
+
+
+ These flyouts are rendered in separate React roots but share the same
+ flyout manager state. Open/close any flyout and watch the shared state
+ update below.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const MultiRootSyncExample: StoryObj = {
+ name: 'Multi-root sync',
+ render: () => ,
};
From f904630da106a234b8ff5586f8ba139bbd8da434 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Wed, 8 Oct 2025 16:56:24 -0700
Subject: [PATCH 6/7] Add ExternalRootChildFlyout to the Multi-root storybook
---
.../manager/flyout_sessions.stories.tsx | 101 ++++++++++++++++++
1 file changed, 101 insertions(+)
diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
index ce436457305..7b949237ecc 100644
--- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx
@@ -35,6 +35,7 @@ import {
} from '../..';
import { EuiFlyout } from '../flyout';
import { useCurrentSession, useFlyoutManager } from './hooks';
+import { EuiFlyoutIsManagedProvider } from './context';
const meta: Meta = {
title: 'Layout/EuiFlyout/Flyout Manager',
@@ -327,8 +328,100 @@ export const MultiSessionExample: StoryObj = {
render: () => ,
};
+const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({
+ parentId,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleOpen = () => {
+ setIsOpen(true);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ };
+
+ return (
+
+
+ Root within {parentId}
+
+
+
+ Open Child Flyout
+
+
+
+
+
+ This is a child flyout rendered in a completely separate React
+ root! It shares the same flyout manager state as the parent.
+
+
+ Parent ID: {parentId}
+
+
+
+
+ );
+};
+
const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
const [isOpen, setIsOpen] = useState(false);
+ const [buttonRoot, setButtonRoot] = useState(null);
+ const buttonContainerRef = useRef(null);
+
+ // Create the button to open the child flyout in a separate React root
+ useEffect(() => {
+ if (isOpen) {
+ // Use setTimeout to ensure the DOM element is rendered
+ const timer = setTimeout(() => {
+ if (buttonContainerRef.current) {
+ const newRoot = createRoot(buttonContainerRef.current);
+ newRoot.render(
+
+ {/*
+ EuiFlyoutIsManagedProvider is required here because the child flyout
+ needs to be detected as being within a managed flyout context for
+ proper routing. Without this, the child flyout would route to
+ EuiFlyoutMain instead of EuiFlyoutChild.
+ */}
+
+
+
+
+ );
+ setButtonRoot(newRoot);
+ }
+ }, 100);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isOpen, id]);
+
+ // Cleanup the button root for opening the child flyout when the main flyout closes
+ useEffect(() => {
+ if (!isOpen && buttonRoot) {
+ buttonRoot.unmount();
+ setButtonRoot(null);
+ }
+ }, [isOpen, buttonRoot]);
+
+ // Cleanup the main flyout's root on unmount
+ useEffect(() => {
+ return () => {
+ if (buttonRoot) {
+ buttonRoot.unmount();
+ }
+ };
+ }, [buttonRoot]);
return (
@@ -354,6 +447,14 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
manager state. Closing it here should update all other flyout
menus and history.
+
+
+ Below is a button rendered in a separate React root that opens a
+ child flyout:
+
+
+ {/* Container for the button React root - inside the main flyout */}
+
From 88d9d68590d7adc7189a0698f061a0dc129fd9c1 Mon Sep 17 00:00:00 2001
From: Timothy Sullivan
Date: Thu, 9 Oct 2025 10:43:25 -0700
Subject: [PATCH 7/7] Render child flyout if `hasActiveSession` and `session
=== undefined`
---
.../eui/src/components/flyout/flyout.test.tsx | 112 ++++++++++++++++++
packages/eui/src/components/flyout/flyout.tsx | 15 ++-
.../manager/flyout_sessions.stories.tsx | 12 +-
3 files changed, 122 insertions(+), 17 deletions(-)
diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx
index 475c0336927..0664794764d 100644
--- a/packages/eui/src/components/flyout/flyout.test.tsx
+++ b/packages/eui/src/components/flyout/flyout.test.tsx
@@ -437,4 +437,116 @@ describe('EuiFlyout', () => {
).not.toBeTruthy();
});
});
+
+ describe('flyout routing logic', () => {
+ // Mock the manager hooks to control routing behavior
+ const mockUseHasActiveSession = jest.fn();
+ const mockUseIsInManagedFlyout = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Mock the manager hooks
+ jest.doMock('./manager', () => ({
+ ...jest.requireActual('./manager'),
+ useHasActiveSession: mockUseHasActiveSession,
+ useIsInManagedFlyout: mockUseIsInManagedFlyout,
+ }));
+ });
+
+ afterEach(() => {
+ jest.dontMock('./manager');
+ });
+
+ it('routes to child flyout when session is undefined and there is an active session', () => {
+ // Setup: There's an active session but flyout is not in managed context
+ mockUseHasActiveSession.mockReturnValue(true);
+ mockUseIsInManagedFlyout.mockReturnValue(false);
+
+ const { getByTestSubject } = render(
+ {}}
+ data-test-subj="child-flyout"
+ // session is undefined (not explicitly set)
+ />
+ );
+
+ // Should render as child flyout (EuiFlyoutChild)
+ const flyout = getByTestSubject('child-flyout');
+ expect(flyout).toBeInTheDocument();
+ });
+
+ it('routes to main flyout when session is explicitly true', () => {
+ // Setup: There's an active session and flyout is not in managed context
+ mockUseHasActiveSession.mockReturnValue(true);
+ mockUseIsInManagedFlyout.mockReturnValue(false);
+
+ const { getByTestSubject } = render(
+ {}}
+ data-test-subj="main-flyout"
+ session={true} // Explicitly creating a new session
+ flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts
+ />
+ );
+
+ // Should render as main flyout (EuiFlyoutMain)
+ const flyout = getByTestSubject('main-flyout');
+ expect(flyout).toBeInTheDocument();
+ });
+
+ it('routes to main flyout when session is explicitly false and there is an active session', () => {
+ // Setup: There's an active session and flyout is not in managed context
+ mockUseHasActiveSession.mockReturnValue(true);
+ mockUseIsInManagedFlyout.mockReturnValue(false);
+
+ const { getByTestSubject } = render(
+ {}}
+ data-test-subj="main-flyout"
+ session={false} // Explicitly not creating a new session, but still routes to main
+ flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts
+ />
+ );
+
+ // Should render as main flyout (EuiFlyoutMain)
+ const flyout = getByTestSubject('main-flyout');
+ expect(flyout).toBeInTheDocument();
+ });
+
+ it('routes to child flyout when in managed context and there is an active session', () => {
+ // Setup: There's an active session and flyout is in managed context
+ mockUseHasActiveSession.mockReturnValue(true);
+ mockUseIsInManagedFlyout.mockReturnValue(true);
+
+ const { getByTestSubject } = render(
+ {}}
+ data-test-subj="child-flyout"
+ session={undefined} // Not explicitly set
+ />
+ );
+
+ // Should render as child flyout (EuiFlyoutChild)
+ const flyout = getByTestSubject('child-flyout');
+ expect(flyout).toBeInTheDocument();
+ });
+
+ it('routes to standard flyout when there is no active session', () => {
+ // Setup: No active session
+ mockUseHasActiveSession.mockReturnValue(false);
+ mockUseIsInManagedFlyout.mockReturnValue(false);
+
+ const { getByTestSubject } = render(
+ {}}
+ data-test-subj="standard-flyout"
+ session={undefined} // Not explicitly set
+ />
+ );
+
+ // Should render as standard flyout (EuiFlyoutComponent)
+ const flyout = getByTestSubject('standard-flyout');
+ expect(flyout).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx
index adf0abea536..2782dfb0209 100644
--- a/packages/eui/src/components/flyout/flyout.tsx
+++ b/packages/eui/src/components/flyout/flyout.tsx
@@ -56,12 +56,15 @@ export const EuiFlyout = forwardRef<
/*
* Flyout routing logic
* 1. Main Flyout: When session={true} OR when there's an active session and this flyout
- * is rendered outside of a managed flyout context.
- * 2. Child Flyout: When there's an active session AND this flyout IS rendered within a
- * managed flyout context.
+ * is rendered outside of a managed flyout context AND explicitly creates a new session.
+ * 2. Child Flyout: When there's an active session AND (this flyout IS rendered within a
+ * managed flyout context OR it's not explicitly creating a new session).
* 3. Standard Flyout: Default fallback when neither condition is met.
*/
- if (session === true || (hasActiveSession && !isInManagedFlyout)) {
+ if (
+ session === true ||
+ (hasActiveSession && !isInManagedFlyout && session !== undefined)
+ ) {
if (isUnmanagedFlyout.current) {
// TODO: @tkajtoch - We need to find a better way to handle the missing event.
onClose?.({} as any);
@@ -72,8 +75,8 @@ export const EuiFlyout = forwardRef<
);
}
- // Else if this flyout is a child of a session AND within a managed flyout context, render EuiChildFlyout.
- if (hasActiveSession && isInManagedFlyout) {
+ // Else if this flyout is a child of a session AND (within a managed flyout context OR not explicitly creating a new session), render EuiChildFlyout.
+ if (hasActiveSession && (isInManagedFlyout || session === undefined)) {
return (
= {
title: 'Layout/EuiFlyout/Flyout Manager',
@@ -178,7 +177,6 @@ const FlyoutSession: React.FC = React.memo((props) => {
isOpen={isChildFlyoutVisible}
id={`childFlyout-${title}`}
flyoutMenuProps={{ title: `${title} - Child` }}
- aria-labelledby="childFlyoutTitle"
size={childSize}
maxWidth={childMaxWidth}
onActive={childFlyoutOnActive}
@@ -387,15 +385,7 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
const newRoot = createRoot(buttonContainerRef.current);
newRoot.render(
- {/*
- EuiFlyoutIsManagedProvider is required here because the child flyout
- needs to be detected as being within a managed flyout context for
- proper routing. Without this, the child flyout would route to
- EuiFlyoutMain instead of EuiFlyoutChild.
- */}
-
-
-
+
);
setButtonRoot(newRoot);