Skip to content

Commit a580a8b

Browse files
author
Brian Vaughn
committed
Update useEditableValue to mirror value cahnges
Previously, the hook initialized local state (in useState) to mirror the prop/state value. Updates to the value were ignored though. (Once the state was initialized, it was never updated.) The new hook updates the local/editable state to mirror the external value unless there are already pending, local edits being made.
1 parent a06d181 commit a580a8b

File tree

7 files changed

+204
-83
lines changed

7 files changed

+204
-83
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
describe('useEditableValue', () => {
11+
let act;
12+
let React;
13+
let ReactDOM;
14+
let useEditableValue;
15+
16+
beforeEach(() => {
17+
const utils = require('./utils');
18+
act = utils.act;
19+
20+
React = require('react');
21+
ReactDOM = require('react-dom');
22+
23+
useEditableValue = require('../devtools/views/hooks').useEditableValue;
24+
});
25+
26+
it('should override editable state when external props are updated', () => {
27+
let state;
28+
29+
function Example({value}) {
30+
const tuple = useEditableValue(value);
31+
state = tuple[0];
32+
return null;
33+
}
34+
35+
const container = document.createElement('div');
36+
ReactDOM.render(<Example value="foo" />, container);
37+
expect(state.editableValue).toEqual('"foo"');
38+
expect(state.externalValue).toEqual('foo');
39+
expect(state.hasPendingChanges).toBe(false);
40+
41+
// If there are NO pending changes,
42+
// an update to the external prop value should override the local/pending value.
43+
ReactDOM.render(<Example value="bar" />, container);
44+
expect(state.editableValue).toEqual('"bar"');
45+
expect(state.externalValue).toEqual('bar');
46+
expect(state.hasPendingChanges).toBe(false);
47+
});
48+
49+
it('should not override editable state when external props are updated if there are pending changes', () => {
50+
let dispatch, state;
51+
52+
function Example({value}) {
53+
const tuple = useEditableValue(value);
54+
state = tuple[0];
55+
dispatch = tuple[1];
56+
return null;
57+
}
58+
59+
const container = document.createElement('div');
60+
ReactDOM.render(<Example value="foo" />, container);
61+
expect(state.editableValue).toEqual('"foo"');
62+
expect(state.externalValue).toEqual('foo');
63+
expect(state.hasPendingChanges).toBe(false);
64+
65+
// Update (local) editable state.
66+
act(() =>
67+
dispatch({
68+
type: 'UPDATE',
69+
editableValue: 'not-foo',
70+
externalValue: 'foo',
71+
}),
72+
);
73+
expect(state.editableValue).toEqual('not-foo');
74+
expect(state.externalValue).toEqual('foo');
75+
expect(state.hasPendingChanges).toBe(true);
76+
77+
// If there ARE pending changes,
78+
// an update to the external prop value should NOT override the local/pending value.
79+
ReactDOM.render(<Example value="bar" />, container);
80+
expect(state.editableValue).toEqual('not-foo');
81+
expect(state.externalValue).toEqual('bar');
82+
expect(state.hasPendingChanges).toBe(true);
83+
});
84+
});

packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import React, {Fragment, useCallback, useRef} from 'react';
10+
import React, {Fragment, useRef} from 'react';
1111
import Button from '../Button';
1212
import ButtonIcon from '../ButtonIcon';
1313
import styles from './EditableValue.css';
@@ -17,51 +17,51 @@ type OverrideValueFn = (path: Array<string | number>, value: any) => void;
1717

1818
type EditableValueProps = {|
1919
className?: string,
20-
initialValue: any,
2120
overrideValueFn: OverrideValueFn,
2221
path: Array<string | number>,
22+
value: any,
2323
|};
2424

2525
export default function EditableValue({
2626
className = '',
27-
initialValue,
2827
overrideValueFn,
2928
path,
29+
value,
3030
}: EditableValueProps) {
3131
const inputRef = useRef<HTMLInputElement | null>(null);
32-
const {
33-
editableValue,
34-
hasPendingChanges,
35-
isValid,
36-
parsedValue,
37-
reset,
38-
update,
39-
} = useEditableValue(initialValue);
32+
const [state, dispatch] = useEditableValue(value);
33+
const {editableValue, hasPendingChanges, isValid, parsedValue} = state;
4034

41-
const handleChange = useCallback(({target}) => update(target.value), [
42-
update,
43-
]);
35+
const reset = () =>
36+
dispatch({
37+
type: 'RESET',
38+
externalValue: value,
39+
});
4440

45-
const handleKeyDown = useCallback(
46-
event => {
47-
// Prevent keydown events from e.g. change selected element in the tree
48-
event.stopPropagation();
41+
const handleChange = ({target}) =>
42+
dispatch({
43+
type: 'UPDATE',
44+
editableValue: target.value,
45+
externalValue: value,
46+
});
4947

50-
switch (event.key) {
51-
case 'Enter':
52-
if (isValid && hasPendingChanges) {
53-
overrideValueFn(path, parsedValue);
54-
}
55-
break;
56-
case 'Escape':
57-
reset();
58-
break;
59-
default:
60-
break;
61-
}
62-
},
63-
[hasPendingChanges, isValid, overrideValueFn, parsedValue, reset],
64-
);
48+
const handleKeyDown = event => {
49+
// Prevent keydown events from e.g. change selected element in the tree
50+
event.stopPropagation();
51+
52+
switch (event.key) {
53+
case 'Enter':
54+
if (isValid && hasPendingChanges) {
55+
overrideValueFn(path, parsedValue);
56+
}
57+
break;
58+
case 'Escape':
59+
reset();
60+
break;
61+
default:
62+
break;
63+
}
64+
};
6565

6666
let placeholder = '';
6767
if (editableValue === undefined) {

packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,9 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
270270
</span>
271271
{typeof overrideValueFn === 'function' ? (
272272
<EditableValue
273-
initialValue={value}
274273
overrideValueFn={overrideValueFn}
275274
path={[]}
275+
value={value}
276276
/>
277277
) : (
278278
// $FlowFixMe Cannot create span element because in property children

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ export default function InspectedElementTree({
105105
:&nbsp;
106106
<EditableValue
107107
className={styles.EditableValue}
108-
initialValue={''}
109108
overrideValueFn={handleNewEntryValue}
110109
path={[newPropName]}
110+
value={''}
111111
/>
112112
</div>
113113
)}

packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ export default function KeyValue({
102102
</span>
103103
{isEditable ? (
104104
<EditableValue
105-
initialValue={value}
106105
overrideValueFn={((overrideValueFn: any): OverrideValueFn)}
107106
path={path}
107+
value={value}
108108
/>
109109
) : (
110110
<span className={styles.Value}>{displayValue}</span>

packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ type Props = {|
619619
children: React$Node,
620620

621621
// Used for automated testing
622+
defaultInspectedElementID?: ?number,
622623
defaultOwnerID?: ?number,
623624
defaultSelectedElementID?: ?number,
624625
defaultSelectedElementIndex?: ?number,
@@ -627,6 +628,7 @@ type Props = {|
627628
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
628629
function TreeContextController({
629630
children,
631+
defaultInspectedElementID,
630632
defaultOwnerID,
631633
defaultSelectedElementID,
632634
defaultSelectedElementIndex,
@@ -700,7 +702,8 @@ function TreeContextController({
700702
ownerFlatTree: null,
701703

702704
// Inspection element panel
703-
inspectedElementID: null,
705+
inspectedElementID:
706+
defaultInspectedElementID == null ? null : defaultInspectedElementID,
704707
});
705708

706709
const dispatchWrapper = useCallback(

packages/react-devtools-shared/src/devtools/views/hooks.js

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,68 +8,102 @@
88
*/
99

1010
import throttle from 'lodash.throttle';
11-
import {useCallback, useEffect, useLayoutEffect, useState} from 'react';
12-
import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
11+
import {
12+
useCallback,
13+
useEffect,
14+
useLayoutEffect,
15+
useReducer,
16+
useState,
17+
} from 'react';
1318
import {
1419
localStorageGetItem,
1520
localStorageSetItem,
1621
} from 'react-devtools-shared/src/storage';
1722
import {sanitizeForParse, smartParse, smartStringify} from '../utils';
1823

19-
type EditableValue = {|
24+
type ACTION_RESET = {|
25+
type: 'RESET',
26+
externalValue: any,
27+
|};
28+
type ACTION_UPDATE = {|
29+
type: 'UPDATE',
30+
editableValue: any,
31+
externalValue: any,
32+
|};
33+
34+
type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE;
35+
type UseEditableValueDispatch = (action: UseEditableValueAction) => void;
36+
type UseEditableValueState = {|
2037
editableValue: any,
38+
externalValue: any,
2139
hasPendingChanges: boolean,
2240
isValid: boolean,
2341
parsedValue: any,
24-
reset: () => void,
25-
update: (newValue: any) => void,
2642
|};
2743

44+
function useEditableValueReducer(state, action) {
45+
switch (action.type) {
46+
case 'RESET':
47+
return {
48+
...state,
49+
editableValue: smartStringify(action.externalValue),
50+
externalValue: action.externalValue,
51+
hasPendingChanges: false,
52+
isValid: true,
53+
parsedValue: action.externalValue,
54+
};
55+
case 'UPDATE':
56+
let isNewValueValid = false;
57+
let newParsedValue;
58+
try {
59+
newParsedValue = smartParse(action.editableValue);
60+
isNewValueValid = true;
61+
} catch (error) {}
62+
return {
63+
...state,
64+
editableValue: sanitizeForParse(action.editableValue),
65+
externalValue: action.externalValue,
66+
hasPendingChanges:
67+
smartStringify(action.externalValue) !== action.editableValue,
68+
isValid: isNewValueValid,
69+
parsedValue: isNewValueValid ? newParsedValue : state.parsedValue,
70+
};
71+
default:
72+
throw new Error(`Invalid action "${action.type}"`);
73+
}
74+
}
75+
2876
// Convenience hook for working with an editable value that is validated via JSON.parse.
2977
export function useEditableValue(
30-
initialValue: any,
31-
initialIsValid?: boolean = true,
32-
): EditableValue {
33-
const [editableValue, setEditableValue] = useState(() =>
34-
smartStringify(initialValue),
35-
);
36-
const [parsedValue, setParsedValue] = useState(initialValue);
37-
const [isValid, setIsValid] = useState(initialIsValid);
78+
externalValue: any,
79+
): [UseEditableValueState, UseEditableValueDispatch] {
80+
const [state, dispatch] = useReducer<
81+
UseEditableValueState,
82+
UseEditableValueAction,
83+
>(useEditableValueReducer, {
84+
editableValue: smartStringify(externalValue),
85+
externalValue,
86+
hasPendingChanges: false,
87+
isValid: true,
88+
parsedValue: externalValue,
89+
});
3890

39-
const reset = useCallback(
40-
() => {
41-
setEditableValue(smartStringify(initialValue));
42-
setParsedValue(initialValue);
43-
setIsValid(initialIsValid);
44-
},
45-
[initialValue, initialIsValid],
46-
);
91+
if (state.externalValue !== externalValue) {
92+
if (!state.hasPendingChanges) {
93+
dispatch({
94+
type: 'RESET',
95+
externalValue,
96+
});
97+
} else {
98+
dispatch({
99+
type: 'UPDATE',
100+
editableValue: state.editableValue,
101+
externalValue,
102+
});
103+
}
104+
}
47105

48-
const update = useCallback(newValue => {
49-
let isNewValueValid = false;
50-
let newParsedValue;
51-
try {
52-
newParsedValue = smartParse(newValue);
53-
isNewValueValid = true;
54-
} catch (error) {}
55-
56-
batchedUpdates(() => {
57-
setEditableValue(sanitizeForParse(newValue));
58-
if (isNewValueValid) {
59-
setParsedValue(newParsedValue);
60-
}
61-
setIsValid(isNewValueValid);
62-
});
63-
}, []);
64-
65-
return {
66-
editableValue,
67-
hasPendingChanges: smartStringify(initialValue) !== editableValue,
68-
isValid,
69-
parsedValue,
70-
reset,
71-
update,
72-
};
106+
return [state, dispatch];
73107
}
74108

75109
export function useIsOverflowing(

0 commit comments

Comments
 (0)