diff --git a/frontend/packages/console-app/src/components/flags/FeatureFlagExtensionLoader.tsx b/frontend/packages/console-app/src/components/flags/FeatureFlagExtensionLoader.tsx index b3d001f8d4c..e24a08b7a44 100644 --- a/frontend/packages/console-app/src/components/flags/FeatureFlagExtensionLoader.tsx +++ b/frontend/packages/console-app/src/components/flags/FeatureFlagExtensionLoader.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { useCallback, useRef, useEffect, useLayoutEffect } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; import type { FeatureFlagHookProvider, ModelFeatureFlag, @@ -22,30 +22,47 @@ import { FeatureFlagExtensionHookResolver } from './FeatureFlagExtensionHookReso /** * React hook that returns a stable {@link SetFeatureFlag} callback. + * + * Updates are always flushed on a microtask so handlers invoked during render + * (including child FeatureFlagExtensionHookResolver re-renders) never dispatch + * synchronously, while async callers (e.g. after a fetch in a + * console.flag/hookProvider) still update without waiting for an unrelated re-render. */ -const useFeatureFlagController = () => { +export const useFeatureFlagController = () => { const dispatch = useConsoleDispatch(); - const flags = useConsoleSelector(({ FLAGS }) => FLAGS); - // Queue of flag updates to be dispatched after render const pendingUpdatesRef = useRef>(new Map()); + const flushScheduledRef = useRef(false); - // Process pending flag updates after render completes. - // This avoids "Cannot update a component while rendering" errors with react-redux 8.x - // because handlers are called during render (they use hooks) but dispatches happen after. - useLayoutEffect(() => { - pendingUpdatesRef.current.forEach((enabled, flag) => { - if (flags.get(flag) !== enabled) { - dispatch(setFlag(flag, enabled)); - } + const flushPendingUpdates = useCallback(() => { + // Detach the current batch first so reentrant setFeatureFlag calls during + // dispatch (e.g. Redux subscribers) write into a fresh map and can schedule a + // follow-up flush instead of being cleared with this batch. + const updates = pendingUpdatesRef.current; + pendingUpdatesRef.current = new Map(); + flushScheduledRef.current = false; + updates.forEach((enabled, flag) => { + dispatch(setFlag(flag, enabled)); }); - pendingUpdatesRef.current.clear(); - }); + }, [dispatch]); - return useCallback((flag, enabled) => { - // Queue the update to be processed after render - pendingUpdatesRef.current.set(flag, enabled); - }, []); + const scheduleFlush = useCallback(() => { + if (flushScheduledRef.current) { + return; + } + flushScheduledRef.current = true; + queueMicrotask(() => { + flushPendingUpdates(); + }); + }, [flushPendingUpdates]); + + return useCallback( + (flag, enabled) => { + pendingUpdatesRef.current.set(flag, enabled); + scheduleFlush(); + }, + [scheduleFlush], + ); }; /** diff --git a/frontend/packages/console-app/src/components/flags/__tests__/FeatureFlagExtensionLoader.spec.tsx b/frontend/packages/console-app/src/components/flags/__tests__/FeatureFlagExtensionLoader.spec.tsx new file mode 100644 index 00000000000..c5d60d4c379 --- /dev/null +++ b/frontend/packages/console-app/src/components/flags/__tests__/FeatureFlagExtensionLoader.spec.tsx @@ -0,0 +1,78 @@ +import { act } from '@testing-library/react'; +import { useStore } from 'react-redux'; +import type { RootState } from '@console/internal/redux'; +import { renderHookWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { createTestPluginStore } from '../../console-operator/__tests__/pluginTestUtils'; +import { useFeatureFlagController } from '../FeatureFlagExtensionLoader'; + +const renderController = () => + renderHookWithProviders(() => useFeatureFlagController(), { + pluginStore: createTestPluginStore(), + }); + +describe('useFeatureFlagController', () => { + it('defers flag updates made during render until after the render completes', async () => { + let flagDuringRender: boolean | undefined; + const { store, result } = renderHookWithProviders( + () => { + const reduxStore = useStore(); + const setFeatureFlag = useFeatureFlagController(); + // Simulate console.flag/hookProvider handlers that set flags during render. + setFeatureFlag('SYNC_FLAG', true); + flagDuringRender = reduxStore.getState().FLAGS.get('SYNC_FLAG'); + return setFeatureFlag; + }, + { pluginStore: createTestPluginStore() }, + ); + + expect(flagDuringRender).toBeUndefined(); + expect(result.current).toEqual(expect.any(Function)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(store.getState().FLAGS.get('SYNC_FLAG')).toBe(true); + }); + + it('applies async flag updates without waiting for another render', async () => { + const { store, result } = renderController(); + + await act(async () => { + result.current('ASYNC_FLAG', true); + await Promise.resolve(); + }); + + expect(store.getState().FLAGS.get('ASYNC_FLAG')).toBe(true); + }); + + it('coalesces consecutive async updates to the latest value', async () => { + const { store, result } = renderController(); + + await act(async () => { + result.current('TOGGLE_FLAG', true); + result.current('TOGGLE_FLAG', false); + await Promise.resolve(); + }); + + expect(store.getState().FLAGS.get('TOGGLE_FLAG')).toBe(false); + }); + + it('preserves flag updates made reentrantly during flush', async () => { + const { store, result } = renderController(); + + await act(async () => { + const unsubscribe = store.subscribe(() => { + if (store.getState().FLAGS.get('REENTRANT_FLAG') === true) { + result.current('REENTRANT_FLAG', false); + unsubscribe(); + } + }); + result.current('REENTRANT_FLAG', true); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(store.getState().FLAGS.get('REENTRANT_FLAG')).toBe(false); + }); +});