Skip to content

Commit fba021a

Browse files
stokesmanciampo
andauthored
InputControl: Fix acceptance of falsy values in controlled updates (#42484)
* Update controlled component test to use falsy value * Handle controlled updates specially in `InputControl` * Add changelog entry * Replace typeless action with new CONTROL action * Further cover controlled updates in unit test * Refine typing of base reducer * Revise comment * Revert runtime string ensurance * Comment purpose of effects * Improve typing of `StateReducer` Co-Authored-By: Marco Ciampini <1083581+ciampo@users.noreply.github.com>
1 parent 8dec6c3 commit fba021a

File tree

5 files changed

+58
-17
lines changed

5 files changed

+58
-17
lines changed

packages/components/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Bug Fix
66

77
- `BorderControl`: Ensure box-sizing is reset for the control ([#42754](https://github.com/WordPress/gutenberg/pull/42754)).
8+
- `InputControl`: Fix acceptance of falsy values in controlled updates ([#42484](https://github.com/WordPress/gutenberg/pull/42484/)).
89

910
### Enhancements
1011

packages/components/src/input-control/reducer/actions.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { DragProps } from '../types';
1010

1111
export const CHANGE = 'CHANGE';
1212
export const COMMIT = 'COMMIT';
13+
export const CONTROL = 'CONTROL';
1314
export const DRAG_END = 'DRAG_END';
1415
export const DRAG_START = 'DRAG_START';
1516
export const DRAG = 'DRAG';
@@ -23,7 +24,7 @@ interface EventPayload {
2324
event?: SyntheticEvent;
2425
}
2526

26-
interface Action< Type, ExtraPayload = {} > {
27+
export interface Action< Type = string, ExtraPayload = {} > {
2728
type: Type;
2829
payload: EventPayload & ExtraPayload;
2930
}
@@ -34,6 +35,7 @@ interface ValuePayload {
3435

3536
export type ChangeAction = Action< typeof CHANGE, ValuePayload >;
3637
export type CommitAction = Action< typeof COMMIT, ValuePayload >;
38+
export type ControlAction = Action< typeof CONTROL, ValuePayload >;
3739
export type PressUpAction = Action< typeof PRESS_UP >;
3840
export type PressDownAction = Action< typeof PRESS_DOWN >;
3941
export type PressEnterAction = Action< typeof PRESS_ENTER >;

packages/components/src/input-control/reducer/reducer.ts

+27-10
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,32 @@ function mergeInitialState(
3939
}
4040

4141
/**
42-
* Creates a reducer that opens the channel for external state subscription
43-
* and modification.
42+
* Creates the base reducer which may be coupled to a specializing reducer.
43+
* As its final step, for all actions other than CONTROL, the base reducer
44+
* passes the state and action on through the specializing reducer. The
45+
* exception for CONTROL actions is because they represent controlled updates
46+
* from props and no case has yet presented for their specialization.
4447
*
45-
* This technique uses the "stateReducer" design pattern:
46-
* https://kentcdodds.com/blog/the-state-reducer-pattern/
47-
*
48-
* @param composedStateReducers A custom reducer that can subscribe and modify state.
48+
* @param composedStateReducers A reducer to specialize state changes.
4949
* @return The reducer.
5050
*/
5151
function inputControlStateReducer(
5252
composedStateReducers: StateReducer
53-
): StateReducer {
53+
): StateReducer< actions.ControlAction > {
5454
return ( state, action ) => {
5555
const nextState = { ...state };
5656

5757
switch ( action.type ) {
58+
/*
59+
* Controlled updates
60+
*/
61+
case actions.CONTROL:
62+
nextState.value = action.payload.value;
63+
nextState.isDirty = false;
64+
nextState._event = undefined;
65+
// Returns immediately to avoid invoking additional reducers.
66+
return nextState;
67+
5868
/**
5969
* Keyboard events
6070
*/
@@ -140,7 +150,7 @@ export function useInputControlStateReducer(
140150
initialState: Partial< InputState > = initialInputControlState,
141151
onChangeHandler: InputChangeCallback
142152
) {
143-
const [ state, dispatch ] = useReducer< StateReducer >(
153+
const [ state, dispatch ] = useReducer(
144154
inputControlStateReducer( stateReducer ),
145155
mergeInitialState( initialState )
146156
);
@@ -188,10 +198,15 @@ export function useInputControlStateReducer(
188198

189199
const currentState = useRef( state );
190200
const refProps = useRef( { value: initialState.value, onChangeHandler } );
201+
202+
// Freshens refs to props and state so that subsequent effects have access
203+
// to their latest values without their changes causing effect runs.
191204
useLayoutEffect( () => {
192205
currentState.current = state;
193206
refProps.current = { value: initialState.value, onChangeHandler };
194207
} );
208+
209+
// Propagates the latest state through onChange.
195210
useLayoutEffect( () => {
196211
if (
197212
currentState.current._event !== undefined &&
@@ -205,14 +220,16 @@ export function useInputControlStateReducer(
205220
} );
206221
}
207222
}, [ state.value, state.isDirty ] );
223+
224+
// Updates the state from props.
208225
useLayoutEffect( () => {
209226
if (
210227
initialState.value !== currentState.current.value &&
211228
! currentState.current.isDirty
212229
) {
213230
dispatch( {
214-
type: actions.RESET,
215-
payload: { value: initialState.value },
231+
type: actions.CONTROL,
232+
payload: { value: initialState.value ?? '' },
216233
} );
217234
}
218235
}, [ initialState.value ] );

packages/components/src/input-control/reducer/state.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Reducer, SyntheticEvent } from 'react';
66
/**
77
* Internal dependencies
88
*/
9-
import type { InputAction } from './actions';
9+
import type { Action, InputAction } from './actions';
1010

1111
export interface InputState {
1212
_event?: SyntheticEvent;
@@ -19,7 +19,12 @@ export interface InputState {
1919
value?: string;
2020
}
2121

22-
export type StateReducer = Reducer< InputState, InputAction >;
22+
export type StateReducer< SpecializedAction = {} > = Reducer<
23+
InputState,
24+
SpecializedAction extends Action
25+
? InputAction | SpecializedAction
26+
: InputAction
27+
>;
2328

2429
export const initialStateReducer: StateReducer = ( state: InputState ) => state;
2530

packages/components/src/input-control/test/index.js

+20-4
Original file line numberDiff line numberDiff line change
@@ -85,34 +85,50 @@ describe( 'InputControl', () => {
8585
expect( spy ).toHaveBeenLastCalledWith( 'Hello there' );
8686
} );
8787

88-
it( 'should work as a controlled component', async () => {
88+
it( 'should work as a controlled component given normal, falsy or nullish values', async () => {
8989
const user = setupUser();
9090
const spy = jest.fn();
91+
const heldKeySet = new Set();
9192
const Example = () => {
9293
const [ state, setState ] = useState( 'one' );
9394
const onChange = ( value ) => {
9495
setState( value );
9596
spy( value );
9697
};
9798
const onKeyDown = ( { key } ) => {
98-
if ( key === 'Escape' ) setState( 'three' );
99+
heldKeySet.add( key );
100+
if ( key === 'Escape' ) {
101+
if ( heldKeySet.has( 'Meta' ) ) setState( 'qux' );
102+
else if ( heldKeySet.has( 'Alt' ) )
103+
setState( undefined );
104+
else setState( '' );
105+
}
99106
};
107+
const onKeyUp = ( { key } ) => heldKeySet.delete( key );
100108
return (
101109
<InputControl
102110
value={ state }
103111
onChange={ onChange }
104112
onKeyDown={ onKeyDown }
113+
onKeyUp={ onKeyUp }
105114
/>
106115
);
107116
};
108117
render( <Example /> );
109118
const input = getInput();
110119

111120
await user.type( input, '2' );
112-
// Make a controlled update.
121+
// Make a controlled update with a falsy value.
113122
await user.keyboard( '{Escape}' );
123+
expect( input ).toHaveValue( '' );
114124

115-
expect( input ).toHaveValue( 'three' );
125+
// Make a controlled update with a normal value.
126+
await user.keyboard( '{Meta>}{Escape}{/Meta}' );
127+
expect( input ).toHaveValue( 'qux' );
128+
129+
// Make a controlled update with a nullish value.
130+
await user.keyboard( '{Alt>}{Escape}{/Alt}' );
131+
expect( input ).toHaveValue( '' );
116132
/*
117133
* onChange called only once. onChange is not called when a
118134
* parent component explicitly passed a (new value) change down to

0 commit comments

Comments
 (0)