diff --git a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx
index bc31c8e8887..434fe2f2373 100644
--- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx
+++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx
@@ -616,6 +616,150 @@ describe('layout_mode', () => {
// The hook should re-evaluate with new window width
expect(mockWindow.requestAnimationFrame).toHaveBeenCalled();
});
+
+ describe('resize listener optimization', () => {
+ it('should NOT attach resize listener when there is no child flyout', () => {
+ // Set up session with only parent flyout (no child)
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ });
+
+ mockUseCurrentChildFlyout.mockReturnValue(null);
+
+ const mockDispatch = jest.fn();
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ },
+ dispatch: mockDispatch,
+ });
+
+ render();
+
+ // Resize listener should NOT be attached when there's no child flyout
+ expect(mockWindow.addEventListener).not.toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+
+ it('should attach resize listener when there is a child flyout', () => {
+ // Set up session with both parent and child flyouts
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: 'main-1',
+ childFlyoutId: 'child-1',
+ });
+
+ mockUseCurrentChildFlyout.mockReturnValue({
+ flyoutId: 'child-1',
+ level: 'child',
+ size: 's',
+ });
+
+ const mockDispatch = jest.fn();
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ },
+ dispatch: mockDispatch,
+ });
+
+ render();
+
+ // Resize listener SHOULD be attached when there's a child flyout
+ expect(mockWindow.addEventListener).toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+
+ it('should remove and re-add resize listener when child flyout changes', () => {
+ // Start with no child flyout
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ });
+
+ mockUseCurrentChildFlyout.mockReturnValue(null);
+
+ const mockDispatch = jest.fn();
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ },
+ dispatch: mockDispatch,
+ });
+
+ const { rerender } = render();
+
+ // Should not have listener initially
+ expect(mockWindow.addEventListener).not.toHaveBeenCalled();
+
+ // Now add a child flyout
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: 'main-1',
+ childFlyoutId: 'child-1',
+ });
+
+ mockUseCurrentChildFlyout.mockReturnValue({
+ flyoutId: 'child-1',
+ level: 'child',
+ size: 's',
+ });
+
+ rerender();
+
+ // Should now have attached the listener
+ expect(mockWindow.addEventListener).toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+
+ // Now remove the child flyout
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ });
+
+ mockUseCurrentChildFlyout.mockReturnValue(null);
+
+ rerender();
+
+ // Should have removed the listener
+ expect(mockWindow.removeEventListener).toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+
+ it('should NOT attach resize listener when there is no parent flyout', () => {
+ // Set up session with no flyouts at all
+ mockUseCurrentSession.mockReturnValue({
+ mainFlyoutId: null,
+ childFlyoutId: null,
+ });
+
+ mockUseCurrentMainFlyout.mockReturnValue(null);
+ mockUseCurrentChildFlyout.mockReturnValue(null);
+
+ const mockDispatch = jest.fn();
+ mockUseFlyoutManager.mockReturnValue({
+ state: {
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ },
+ dispatch: mockDispatch,
+ });
+
+ render();
+
+ // Resize listener should NOT be attached when there are no flyouts
+ expect(mockWindow.addEventListener).not.toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+ });
});
describe('useApplyFlyoutLayoutMode with fill size', () => {
diff --git a/packages/eui/src/components/flyout/manager/layout_mode.ts b/packages/eui/src/components/flyout/manager/layout_mode.ts
index 48e57a85c56..b7250fdd7a1 100644
--- a/packages/eui/src/components/flyout/manager/layout_mode.ts
+++ b/packages/eui/src/components/flyout/manager/layout_mode.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import React, { useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEuiTheme } from '../../../services';
import { setLayoutMode } from './actions';
import {
@@ -42,17 +42,28 @@ export const useApplyFlyoutLayoutMode = () => {
typeof window !== 'undefined' ? window.innerWidth : Infinity
);
- const setMode = React.useCallback(
+ // Only set up resize listener when there's an active flyout
+ const hasFlyouts = Boolean(parentFlyoutId);
+ // Layout mode only matters when there's a child flyout to position
+ const hasChildFlyout = Boolean(childFlyoutId);
+
+ // Extract specific context values to avoid depending on the entire context object
+ // which gets a new reference on every state update
+ const dispatch = context?.dispatch;
+ const currentLayoutMode = context?.state?.layoutMode;
+
+ const setMode = useCallback(
(layoutMode: EuiFlyoutLayoutMode) => {
- if (context?.dispatch && layoutMode !== context.state.layoutMode) {
- context.dispatch(setLayoutMode(layoutMode));
+ if (dispatch) {
+ dispatch(setLayoutMode(layoutMode));
}
},
- [context]
+ [dispatch]
);
useEffect(() => {
- if (typeof window === 'undefined') {
+ // Skip if no child flyout - layout mode only matters with multiple flyouts
+ if (typeof window === 'undefined' || !hasChildFlyout) {
return;
}
@@ -73,15 +84,15 @@ export const useApplyFlyoutLayoutMode = () => {
}
window.removeEventListener('resize', handleResize);
};
- }, []);
+ }, [hasChildFlyout]);
- useEffect(() => {
- if (!context) {
- return;
+ // Calculate the desired layout mode based on current conditions
+ const desiredLayoutMode = useMemo(() => {
+ // Skip calculation if no flyouts open
+ if (!hasFlyouts) {
+ return null;
}
- const currentLayoutMode = context.state.layoutMode;
-
// Thresholds to prevent thrashing near the breakpoint.
const THRESHOLD_TO_SIDE_BY_SIDE = 85;
const THRESHOLD_TO_STACKED = 95;
@@ -92,16 +103,11 @@ export const useApplyFlyoutLayoutMode = () => {
// `composeFlyoutSizing` in `flyout.styles.ts` multiplied
// by 2 (open flyouts side-by-side).
if (windowWidth < Math.round(euiTheme.breakpoint.s * 1.4)) {
- if (currentLayoutMode !== LAYOUT_MODE_STACKED) {
- setMode(LAYOUT_MODE_STACKED);
- }
- return;
+ return LAYOUT_MODE_STACKED;
}
if (!childFlyoutId) {
- if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE)
- setMode(LAYOUT_MODE_SIDE_BY_SIDE);
- return;
+ return LAYOUT_MODE_SIDE_BY_SIDE;
}
let parentWidthValue = parentWidth;
@@ -116,14 +122,11 @@ export const useApplyFlyoutLayoutMode = () => {
}
if (!parentWidthValue || !childWidthValue) {
- if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE)
- setMode(LAYOUT_MODE_SIDE_BY_SIDE);
- return;
+ return LAYOUT_MODE_SIDE_BY_SIDE;
}
const combinedWidth = parentWidthValue + childWidthValue;
const combinedWidthPercentage = (combinedWidth / windowWidth) * 100;
- let newLayoutMode: EuiFlyoutLayoutMode;
// Handle fill size flyouts: keep layout as side-by-side when fill flyout is present
// This allows fill flyouts to dynamically calculate their width based on the other in the pair
@@ -131,39 +134,37 @@ export const useApplyFlyoutLayoutMode = () => {
// For fill flyouts, we want to maintain side-by-side layout to enable dynamic width calculation
// Only stack if the viewport is too small (below the small breakpoint)
if (windowWidth >= Math.round(euiTheme.breakpoint.s * 1.4)) {
- if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE) {
- setMode(LAYOUT_MODE_SIDE_BY_SIDE);
- }
- return;
+ return LAYOUT_MODE_SIDE_BY_SIDE;
}
}
if (currentLayoutMode === LAYOUT_MODE_STACKED) {
- newLayoutMode =
- combinedWidthPercentage <= THRESHOLD_TO_SIDE_BY_SIDE
- ? LAYOUT_MODE_SIDE_BY_SIDE
- : LAYOUT_MODE_STACKED;
+ return combinedWidthPercentage <= THRESHOLD_TO_SIDE_BY_SIDE
+ ? LAYOUT_MODE_SIDE_BY_SIDE
+ : LAYOUT_MODE_STACKED;
} else {
- newLayoutMode =
- combinedWidthPercentage >= THRESHOLD_TO_STACKED
- ? LAYOUT_MODE_STACKED
- : LAYOUT_MODE_SIDE_BY_SIDE;
- }
-
- if (currentLayoutMode !== newLayoutMode) {
- setMode(newLayoutMode);
+ return combinedWidthPercentage >= THRESHOLD_TO_STACKED
+ ? LAYOUT_MODE_STACKED
+ : LAYOUT_MODE_SIDE_BY_SIDE;
}
}, [
+ hasFlyouts,
windowWidth,
- context,
+ euiTheme,
+ childFlyoutId,
parentWidth,
- setMode,
childWidth,
- childFlyoutId,
parentFlyout?.size,
childFlyout?.size,
- euiTheme,
+ currentLayoutMode,
]);
+
+ // Apply the desired layout mode when it differs from the current mode
+ useEffect(() => {
+ if (desiredLayoutMode && currentLayoutMode !== desiredLayoutMode) {
+ setMode(desiredLayoutMode);
+ }
+ }, [desiredLayoutMode, currentLayoutMode, setMode]);
};
/** Convert a flyout `size` value to a pixel width using theme breakpoints. */