Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
766115c
[fix] Keep flyout state in sync across different React rendering roots.
clintandrewhall Oct 6, 2025
0e15ec2
Merge branch 'feat/flyout-system' into exploration/flyout/external-sync
tsullivan Oct 6, 2025
dd5eca0
Access historyItems from the store
tsullivan Oct 7, 2025
04aaa37
refactor: migrate Flyout Manager from reducer to store-based architec…
tsullivan Oct 7, 2025
39ab1bd
whitespace change
tsullivan Oct 7, 2025
e4de6b1
Clean up storybooks
tsullivan Oct 8, 2025
f904630
Add ExternalRootChildFlyout to the Multi-root storybook
tsullivan Oct 8, 2025
88d9d68
Render child flyout if `hasActiveSession` and `session === undefined`
tsullivan Oct 9, 2025
aa4db88
Merge branch 'feat/flyout-system' into exploration/flyout/external-sync
tsullivan Oct 13, 2025
1cb9d38
Fix memoization issue with history items getter creating new function…
tsullivan Oct 13, 2025
eebf5ef
Update provider test, reset singleton store after each
tsullivan Oct 13, 2025
277a3c3
Fix a memory leak in multi-root story
tsullivan Oct 13, 2025
a0ee5b6
Tidy up main/child routing logic
tsullivan Oct 13, 2025
f9f9f0f
fix build
tsullivan Oct 13, 2025
37dc653
Merge branch 'feat/flyout-system' into exploration/flyout/external-sync
tsullivan Oct 14, 2025
78e225a
[Flyout System] Fix session=false use cases
tsullivan Oct 14, 2025
a4af03a
Small improvements
tsullivan Oct 14, 2025
8901717
[Flyout Top Menu] Fix close button in menu for non-managed flyouts
tsullivan Oct 2, 2025
1c4b78d
Merge branch 'flyout-system/fix-session-false' into exploration/flyou…
tsullivan Oct 14, 2025
f68e9c2
Update packages/eui/src/components/flyout/flyout.tsx
tsullivan Oct 15, 2025
ebc3870
Merge branch 'feat/flyout-system' into exploration/flyout/external-sync
tsullivan Oct 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to other reviewers: useSyncExternalStore is shipped with React starting from version 18.0. We need this shim for React 17 support

"uuid": "^8.3.0",
"vfile": "^4.2.1"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const EuiCollapsibleNav: FunctionComponent<EuiCollapsibleNavProps> = ({
const flyout = (
<EuiFlyout
id={flyoutID}
session={false}
css={cssStyles}
className={classes}
// Flyout props we set different defaults for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ const _EuiCollapsibleNavBeta: FunctionComponent<EuiCollapsibleNavBetaProps> = ({
aria-label={defaultAriaLabel}
{...rest} // EuiCollapsibleNav is significantly less permissive than EuiFlyout
id={flyoutID}
session={false}
css={cssStyles}
className={classes}
size={width}
Expand Down
6 changes: 6 additions & 0 deletions packages/eui/src/components/flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ The main flyout component that serves as the entry point for all flyout function
- **Standard flyouts**: Default behavior renders `EuiFlyoutComponent`
- **Resizable flyouts**: `EuiFlyoutResizable` component exists but is not integrated into main routing logic

#### `session` Prop Behavior
The `session` prop controls whether a flyout participates in the session management system:
- **`session={true}`**: Explicitly opt-in to session management. The flyout will be managed as a main flyout.
- **`session={false}`**: Explicitly opt-out of session management. The flyout will render as an unmanaged standard flyout, bypassing all session logic. This is useful for wrapper components like `EuiCollapsibleNav` that manage their own lifecycle.
- **`session={undefined}`** (default): Automatically participate in sessions if one is active. If no session is active, renders as a standard flyout.

### `src/components/flyout/flyout.component.tsx`
The core flyout implementation with comprehensive functionality:
- **Props**: Extensive configuration options including size, padding, positioning, focus management
Expand Down
112 changes: 112 additions & 0 deletions packages/eui/src/components/flyout/flyout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<EuiFlyout
onClose={() => {}}
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(
<EuiFlyout
onClose={() => {}}
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(
<EuiFlyout
onClose={() => {}}
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(
<EuiFlyout
onClose={() => {}}
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(
<EuiFlyout
onClose={() => {}}
data-test-subj="standard-flyout"
session={undefined} // Not explicitly set
/>
);

// Should render as standard flyout (EuiFlyoutComponent)
const flyout = getByTestSubject('standard-flyout');
expect(flyout).toBeInTheDocument();
});
});
});
72 changes: 39 additions & 33 deletions packages/eui/src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@ import {
type EuiFlyoutComponentProps,
} from './flyout.component';

import {
EuiFlyoutChild,
EuiFlyoutMain,
useHasActiveSession,
useIsInManagedFlyout,
} from './manager';
import { EuiFlyoutChild, EuiFlyoutMain, useHasActiveSession } from './manager';
import { EuiFlyoutMenuContext } from './flyout_menu_context';

export type {
EuiFlyoutSize,
Expand Down Expand Up @@ -51,42 +47,52 @@ export const EuiFlyout = forwardRef<
usePropsWithComponentDefaults('EuiFlyout', props);
const hasActiveSession = useHasActiveSession();
const isUnmanagedFlyout = useRef(false);
const isInManagedFlyout = useIsInManagedFlyout();

/*
* 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.
* 3. Standard Flyout: Default fallback when neither condition is met.
* Flyout routing logic:
* - session={true} → Main flyout (creates new session)
* - session={undefined} + active session → Child flyout (auto-joins, works across React roots!)
* - session={undefined} + no session → Standard flyout
* - session={false} → Standard flyout (explicit opt-out)
*/
if (session === true || (hasActiveSession && !isInManagedFlyout)) {
if (isUnmanagedFlyout.current) {
// TODO: @tkajtoch - We need to find a better way to handle the missing event.
onClose?.({} as any);
return null;
if (session !== false) {
if (session === true) {
if (isUnmanagedFlyout.current) {
// TODO: @tkajtoch - We need to find a better way to handle the missing event.
onClose?.({} as any);
return null;
}
return (
<EuiFlyoutMain
{...rest}
onClose={onClose}
onActive={onActive}
as="div"
/>
);
}
return (
<EuiFlyoutMain {...rest} onClose={onClose} onActive={onActive} as="div" />
);
}

// Else if this flyout is a child of a session AND within a managed flyout context, render EuiChildFlyout.
if (hasActiveSession && isInManagedFlyout) {
return (
<EuiFlyoutChild
{...rest}
onClose={onClose}
onActive={onActive}
as="div"
/>
);
// Auto-join existing session as child
if (hasActiveSession && session === undefined) {
return (
<EuiFlyoutChild
{...rest}
onClose={onClose}
onActive={onActive}
as="div"
/>
);
}
}

// TODO: if resizeable={true}, render EuiResizableFlyout.

isUnmanagedFlyout.current = true;
return <EuiFlyoutComponent {...rest} onClose={onClose} as={as} ref={ref} />;
return (
<EuiFlyoutMenuContext.Provider value={{ onClose }}>
<EuiFlyoutComponent {...rest} onClose={onClose} as={as} ref={ref} />
</EuiFlyoutMenuContext.Provider>
);
});

EuiFlyout.displayName = 'EuiFlyout';
11 changes: 4 additions & 7 deletions packages/eui/src/components/flyout/manager/flyout_main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
14 changes: 10 additions & 4 deletions packages/eui/src/components/flyout/manager/flyout_managed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ export const EuiManagedFlyout = ({
const flyoutId = useFlyoutId(id);
const flyoutRef = useRef<HTMLDivElement>(null);

const { addFlyout, closeFlyout, setFlyoutWidth, goBack, getHistoryItems } =
useFlyoutManager();
const {
addFlyout,
closeFlyout,
setFlyoutWidth,
goBack,
historyItems: _historyItems,
} = useFlyoutManager();
const parentSize = useParentFlyoutSize(flyoutId);
const parentFlyout = useCurrentMainFlyout();
const layoutMode = useFlyoutLayoutMode();
Expand Down Expand Up @@ -219,8 +224,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;
Expand Down
Loading