diff --git a/change/@fluentui-react-utilities-66d57a65-a181-4667-ba3e-a17909109d03.json b/change/@fluentui-react-utilities-66d57a65-a181-4667-ba3e-a17909109d03.json new file mode 100644 index 0000000000000..625de0c207761 --- /dev/null +++ b/change/@fluentui-react-utilities-66d57a65-a181-4667-ba3e-a17909109d03.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(react-utilities): fix dispatcher behavior", + "packageName": "@fluentui/react-utilities", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-utilities/src/hooks/useControllableState.test.ts b/packages/react-components/react-utilities/src/hooks/useControllableState.test.ts index 284bdfe9877bd..8f845f826f261 100644 --- a/packages/react-components/react-utilities/src/hooks/useControllableState.test.ts +++ b/packages/react-components/react-utilities/src/hooks/useControllableState.test.ts @@ -35,6 +35,27 @@ describe('useControllableState', () => { expect(result.current[0]).toBe(initialState); }); + it('should call setState() with a value when is controlled', () => { + const spy = jest.fn(); + const { result } = renderHook(() => + useControllableState({ state: 'foo', defaultState: undefined, initialState: '' }), + ); + + const [, setState] = result.current; + + act(() => { + setState(prevState => { + spy(prevState); + return prevState; + }); + }); + + expect(result.current[0]).toEqual('foo'); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('foo'); + }); + it.each([ ['', true], ['factory', () => true], diff --git a/packages/react-components/react-utilities/src/hooks/useControllableState.ts b/packages/react-components/react-utilities/src/hooks/useControllableState.ts index af679fd104899..ba3b5db5f85f7 100644 --- a/packages/react-components/react-utilities/src/hooks/useControllableState.ts +++ b/packages/react-components/react-utilities/src/hooks/useControllableState.ts @@ -19,6 +19,10 @@ export type UseControllableStateOptions = { initialState: State; }; +function isFactoryDispatch(newState: React.SetStateAction): newState is (prevState: State) => State { + return typeof newState === 'function'; +} + /** * @internal * @@ -45,17 +49,29 @@ export const useControllableState = ( } return isInitializer(options.defaultState) ? options.defaultState() : options.defaultState; }); - return useIsControlled(options.state) ? [options.state, noop] : [internalState, setInternalState]; + + // Heads up! + // This part is specific for controlled mode and mocks behavior of React dispatcher function. + + const stateValueRef = React.useRef(options.state); + + React.useEffect(() => { + stateValueRef.current = options.state; + }, [options.state]); + + const setControlledState = React.useCallback((newState: React.SetStateAction) => { + if (isFactoryDispatch(newState)) { + newState(stateValueRef.current as State); + } + }, []); + + return useIsControlled(options.state) ? [options.state, setControlledState] : [internalState, setInternalState]; }; function isInitializer(value: State | (() => State)): value is () => State { return typeof value === 'function'; } -function noop() { - /* noop */ -} - /** * Helper hook to handle previous comparison of controlled/uncontrolled * Prints an error when isControlled value switches between subsequent renders