Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,49 @@ import { FeatureFlagExtensionHookResolver } from './FeatureFlagExtensionHookReso

/**
* React hook that returns a stable {@link SetFeatureFlag} callback.
*
* Sync calls during render are deferred until after layout effects so we avoid
* "Cannot update a component while rendering" with react-redux 8.x (handlers may
* invoke this while rendering). Async calls (e.g. after a fetch in a
* console.flag/hookProvider) flush immediately so flag-gated extensions update
* without waiting for an unrelated re-render.
*/
const useFeatureFlagController = () => {
export const useFeatureFlagController = () => {
const dispatch = useConsoleDispatch();
const flags = useConsoleSelector(({ FLAGS }) => FLAGS);
const flagsRef = useRef(flags);
flagsRef.current = flags;

// Queue of flag updates to be dispatched after render
const pendingUpdatesRef = useRef<Map<string, boolean>>(new Map());
const isRenderingRef = useRef(true);

// 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(() => {
// Mark the render phase; cleared in the layout effect below.
isRenderingRef.current = true;

const flushPendingUpdates = useCallback(() => {
pendingUpdatesRef.current.forEach((enabled, flag) => {
if (flags.get(flag) !== enabled) {
if (flagsRef.current.get(flag) !== enabled) {
dispatch(setFlag(flag, enabled));
}
});
pendingUpdatesRef.current.clear();
Comment on lines +45 to 51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update flagsRef after each dispatched flag update.

Line 47 reads the last selector snapshot, not the last dispatched value. If a flag is currently false, two post-layout calls can set it to true and then false before React re-renders. The first call dispatches true. The second call sees the stale false value and skips its required dispatch. The Redux flag remains true.

Record the dispatched value in flagsRef before clearing the pending update. Add a regression test for consecutive true then false calls without a selector re-render.

Proposed fix
       if (flagsRef.current.get(flag) !== enabled) {
         dispatch(setFlag(flag, enabled));
+        flagsRef.current = flagsRef.current.set(flag, enabled);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const flushPendingUpdates = useCallback(() => {
pendingUpdatesRef.current.forEach((enabled, flag) => {
if (flags.get(flag) !== enabled) {
if (flagsRef.current.get(flag) !== enabled) {
dispatch(setFlag(flag, enabled));
}
});
pendingUpdatesRef.current.clear();
const flushPendingUpdates = useCallback(() => {
pendingUpdatesRef.current.forEach((enabled, flag) => {
if (flagsRef.current.get(flag) !== enabled) {
dispatch(setFlag(flag, enabled));
flagsRef.current = flagsRef.current.set(flag, enabled);
}
});
pendingUpdatesRef.current.clear();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/packages/console-app/src/components/flags/FeatureFlagExtensionLoader.tsx`
around lines 45 - 51, Update flushPendingUpdates so that after dispatching each
changed flag via setFlag, flagsRef.current records the dispatched enabled value
before pendingUpdatesRef.current is cleared. Add a regression test covering
consecutive true then false updates without a selector re-render, verifying both
dispatches occur.

}, [dispatch]);

useLayoutEffect(() => {
isRenderingRef.current = false;
flushPendingUpdates();
});

return useCallback<SetFeatureFlag>((flag, enabled) => {
// Queue the update to be processed after render
pendingUpdatesRef.current.set(flag, enabled);
}, []);
return useCallback<SetFeatureFlag>(
(flag, enabled) => {
pendingUpdatesRef.current.set(flag, enabled);
if (!isRenderingRef.current) {
flushPendingUpdates();
}
},
[flushPendingUpdates],
);
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { act, renderHook } from '@testing-library/react';
import { Map as ImmutableMap } from 'immutable';
import { setFlag } from '@console/internal/actions/flags';
import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch';
import { useConsoleSelector } from '@console/shared/src/hooks/useConsoleSelector';
import { useFeatureFlagController } from '../FeatureFlagExtensionLoader';

jest.mock('@console/shared/src/hooks/useConsoleSelector', () => ({
useConsoleSelector: jest.fn(),
}));

jest.mock('@console/shared/src/hooks/useConsoleDispatch', () => ({
useConsoleDispatch: jest.fn(),
}));

jest.mock('@console/internal/actions/flags', () => ({
...jest.requireActual('@console/internal/actions/flags'),
setFlag: jest.fn((flag: string, value: boolean) => ({
type: 'setFlag',
payload: { flag, value },
})),
}));

const mockDispatch = jest.fn();
const mockUseSelector = useConsoleSelector as jest.Mock;
const mockUseDispatch = useConsoleDispatch as jest.Mock;
const mockSetFlag = setFlag as jest.MockedFunction<typeof setFlag>;

describe('useFeatureFlagController', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseDispatch.mockReturnValue(mockDispatch);
mockUseSelector.mockReturnValue(ImmutableMap());
});

it('defers flag updates made during render until after layout effects', () => {
const { result } = renderHook(() => {
const setFeatureFlag = useFeatureFlagController();
// Simulate console.flag/hookProvider handlers that set flags during render.
setFeatureFlag('SYNC_FLAG', true);
return setFeatureFlag;
});

expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockSetFlag).toHaveBeenCalledWith('SYNC_FLAG', true);
expect(result.current).toEqual(expect.any(Function));
});
Comment on lines +36 to +47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that no dispatch occurs during render.

Line 44 runs after layout effects complete. A regression that dispatches directly during render still produces one dispatch and passes this test. Capture the dispatch count immediately after setFeatureFlag in the render callback, then assert that the count did not change until the layout effect flushes the update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/packages/console-app/src/components/flags/__tests__/FeatureFlagExtensionLoader.spec.tsx`
around lines 36 - 47, Update the renderHook test around useFeatureFlagController
to record mockDispatch’s call count immediately after setFeatureFlag runs during
render, assert it is unchanged before layout effects flush, then retain the
post-render assertions verifying the deferred update dispatches once with the
expected flag.


it('dispatches async flag updates immediately without waiting for another render', () => {
const { result } = renderHook(() => useFeatureFlagController());

mockDispatch.mockClear();
mockSetFlag.mockClear();

act(() => {
result.current('ASYNC_FLAG', true);
});

expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockSetFlag).toHaveBeenCalledWith('ASYNC_FLAG', true);
});

it('does not redispatch when the flag already has the requested value', () => {
mockUseSelector.mockReturnValue(ImmutableMap({ EXISTING_FLAG: true }));
const { result } = renderHook(() => useFeatureFlagController());

mockDispatch.mockClear();

act(() => {
result.current('EXISTING_FLAG', true);
});

expect(mockDispatch).not.toHaveBeenCalled();
});
});