From 31770ab2d5235fb752c7dd5f572b7296c7d5fe97 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Sun, 21 Feb 2021 14:30:11 -0600 Subject: [PATCH 1/2] feat(react): add useControllableState --- .../__tests__/useControllableState-test.js | 90 +++++++++++++++++++ .../src/internal/useControllableState.js | 75 ++++++++++++++++ packages/react/src/internal/warn.js | 30 +++++++ 3 files changed, 195 insertions(+) create mode 100644 packages/react/src/internal/__tests__/useControllableState-test.js create mode 100644 packages/react/src/internal/useControllableState.js create mode 100644 packages/react/src/internal/warn.js diff --git a/packages/react/src/internal/__tests__/useControllableState-test.js b/packages/react/src/internal/__tests__/useControllableState-test.js new file mode 100644 index 000000000000..8ed2770669a8 --- /dev/null +++ b/packages/react/src/internal/__tests__/useControllableState-test.js @@ -0,0 +1,90 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useState } from 'react'; +import { useControllableState } from '../useControllableState'; + +describe('useControllableState', () => { + afterEach(cleanup); + + test('uncontrolled', () => { + render(); + userEvent.type(screen.getByTestId('input'), 'test'); + expect(screen.getByTestId('input').value).toBe('test'); + }); + + test('controlled', () => { + render(); + userEvent.type(screen.getByTestId('input'), 'test'); + expect(screen.getByTestId('input').value).toBe('test'); + }); + + test('controlled to uncontrolled', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + + userEvent.click(screen.getByTestId('toggle')); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('uncontrolled to controlled', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + + userEvent.click(screen.getByTestId('toggle')); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +function TextInput({ onChange, value: controlledValue }) { + const [value, setValue] = useControllableState(controlledValue, onChange, ''); + + function handleOnChange(event) { + setValue(event.target.value); + } + + return ( + + ); +} + +function ControlledTextInput() { + const [value, setValue] = useState(''); + return ; +} + +function Toggle({ defaultControlled }) { + const [value, setValue] = useState(''); + const [controlled, setControlled] = useState(defaultControlled); + return ( + <> + + + + ); +} diff --git a/packages/react/src/internal/useControllableState.js b/packages/react/src/internal/useControllableState.js new file mode 100644 index 000000000000..be02db383ebb --- /dev/null +++ b/packages/react/src/internal/useControllableState.js @@ -0,0 +1,75 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useRef, useState } from 'react'; +import { warn } from './warn'; + +export function useControllableState( + controlledState, + controlledSetState, + defaultValue +) { + const controlled = useRef(controlledState !== undefined); + const [state, internalSetState] = useState(() => { + if (controlled.current === true) { + return controlledState; + } + return defaultValue; + }); + + // If the owner is controlling the component prop value, keep the controlled + // state value and the internal state value in sync. + // + // We guard on `undefined` to prevent downstream breakage of controlled + // components (like ). When the controlled state switches to + // `undefined`, we are moving from controlled to uncontrolled. + if ( + controlled.current === true && + controlledState !== state && + controlledState !== undefined + ) { + internalSetState(controlledState); + } + + function setState(stateOrUpdater) { + if (controlled.current === true) { + controlledSetState(stateOrUpdater); + } else { + internalSetState(stateOrUpdater); + } + } + + useEffect(() => { + // Uncontrolled -> Controlled + // If the component prop is uncontrolled, the prop value should be undefined + if (controlled.current === false && controlledState !== undefined) { + warn( + false, + 'A component is changing an uncontrolled component to be controlled. ' + + 'This is likely caused by the value changing to a defined value ' + + 'from undefined. Decide between using a controlled or uncontrolled ' + + 'value for the lifetime of the component. ' + + 'More info: https://reactjs.org/link/controlled-components' + ); + } + + // Controlled -> Uncontrolled + // If the component prop is controlled, the prop value should be defined + if (controlled.current == true && controlledState === undefined) { + warn( + false, + 'A component is changing a controlled component to be uncontrolled. ' + + 'This is likely caused by the value changing to an undefined value ' + + 'from a defined one. Decide between using a controlled or ' + + 'uncontrolled value for the lifetime of the component. ' + + 'More info: https://reactjs.org/link/controlled-components' + ); + } + }, [controlledState]); + + return [state, setState]; +} diff --git a/packages/react/src/internal/warn.js b/packages/react/src/internal/warn.js new file mode 100644 index 000000000000..3993b18cf0ae --- /dev/null +++ b/packages/react/src/internal/warn.js @@ -0,0 +1,30 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +const emptyFunction = function () {}; + +const warn = __DEV__ + ? function warn(condition, format, ...args) { + if (format === undefined) { + throw new Error( + '`warn(condition, format, ...args)` requires a warning ' + + 'message argument' + ); + } + + if (!condition) { + let index = 0; + const message = format.replace(/%s/g, () => { + return args[index++]; + }); + + console.warn('Warning: ' + message); + } + } + : emptyFunction; + +export { warn }; From 522af92677bcdeacc491c8e6ae64d67cec86fd2f Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 4 Mar 2021 12:26:47 -0600 Subject: [PATCH 2/2] Update packages/react/src/internal/useControllableState.js --- packages/react/src/internal/useControllableState.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/internal/useControllableState.js b/packages/react/src/internal/useControllableState.js index be02db383ebb..68517e38c6f8 100644 --- a/packages/react/src/internal/useControllableState.js +++ b/packages/react/src/internal/useControllableState.js @@ -59,7 +59,7 @@ export function useControllableState( // Controlled -> Uncontrolled // If the component prop is controlled, the prop value should be defined - if (controlled.current == true && controlledState === undefined) { + if (controlled.current === true && controlledState === undefined) { warn( false, 'A component is changing a controlled component to be uncontrolled. ' +