Update useEditableValue hook to sync external value changes (#16878)
* 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. * Optimistic CHANGELOG update * Added additional useEditableValue() unit test cases
Brian Vaughn committed
Sep 25, 2019 at 10:46 UTC
fa1a3262271be165820fa51daeb036ba260205a3
8 files changed
+295
-83
packages/react-devtools-shared/src/__tests__/useEditableValue-test.js
new
+173
@@ -0,0 +1,173 @@
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={1} />, container);
37
+ expect(state.editableValue).toEqual('1');
38
+ expect(state.externalValue).toEqual(1);
39
+ expect(state.parsedValue).toEqual(1);
40
+ expect(state.hasPendingChanges).toBe(false);
41
+ expect(state.isValid).toBe(true);
42
+
43
+ // If there are NO pending changes,
44
+ // an update to the external prop value should override the local/pending value.
45
+ ReactDOM.render(<Example value={2} />, container);
46
+ expect(state.editableValue).toEqual('2');
47
+ expect(state.externalValue).toEqual(2);
48
+ expect(state.parsedValue).toEqual(2);
49
+ expect(state.hasPendingChanges).toBe(false);
50
+ expect(state.isValid).toBe(true);
51
+ });
52
+
53
+ it('should not override editable state when external props are updated if there are pending changes', () => {
54
+ let dispatch, state;
55
+
56
+ function Example({value}) {
57
+ const tuple = useEditableValue(value);
58
+ state = tuple[0];
59
+ dispatch = tuple[1];
60
+ return null;
61
+ }
62
+
63
+ const container = document.createElement('div');
64
+ ReactDOM.render(<Example value={1} />, container);
65
+ expect(state.editableValue).toEqual('1');
66
+ expect(state.externalValue).toEqual(1);
67
+ expect(state.parsedValue).toEqual(1);
68
+ expect(state.hasPendingChanges).toBe(false);
69
+ expect(state.isValid).toBe(true);
70
+
71
+ // Update (local) editable state.
72
+ act(() =>
73
+ dispatch({
74
+ type: 'UPDATE',
75
+ editableValue: '2',
76
+ externalValue: 1,
77
+ }),
78
+ );
79
+ expect(state.editableValue).toEqual('2');
80
+ expect(state.externalValue).toEqual(1);
81
+ expect(state.parsedValue).toEqual(2);
82
+ expect(state.hasPendingChanges).toBe(true);
83
+ expect(state.isValid).toBe(true);
84
+
85
+ // If there ARE pending changes,
86
+ // an update to the external prop value should NOT override the local/pending value.
87
+ ReactDOM.render(<Example value={3} />, container);
88
+ expect(state.editableValue).toEqual('2');
89
+ expect(state.externalValue).toEqual(3);
90
+ expect(state.parsedValue).toEqual(2);
91
+ expect(state.hasPendingChanges).toBe(true);
92
+ expect(state.isValid).toBe(true);
93
+ });
94
+
95
+ it('should parse edits to ensure valid JSON', () => {
96
+ let dispatch, state;
97
+
98
+ function Example({value}) {
99
+ const tuple = useEditableValue(value);
100
+ state = tuple[0];
101
+ dispatch = tuple[1];
102
+ return null;
103
+ }
104
+
105
+ const container = document.createElement('div');
106
+ ReactDOM.render(<Example value={1} />, container);
107
+ expect(state.editableValue).toEqual('1');
108
+ expect(state.externalValue).toEqual(1);
109
+ expect(state.parsedValue).toEqual(1);
110
+ expect(state.hasPendingChanges).toBe(false);
111
+ expect(state.isValid).toBe(true);
112
+
113
+ // Update (local) editable state.
114
+ act(() =>
115
+ dispatch({
116
+ type: 'UPDATE',
117
+ editableValue: '"a',
118
+ externalValue: 1,
119
+ }),
120
+ );
121
+ expect(state.editableValue).toEqual('"a');
122
+ expect(state.externalValue).toEqual(1);
123
+ expect(state.parsedValue).toEqual(1);
124
+ expect(state.hasPendingChanges).toBe(true);
125
+ expect(state.isValid).toBe(false);
126
+ });
127
+
128
+ it('should reset to external value upon request', () => {
129
+ let dispatch, state;
130
+
131
+ function Example({value}) {
132
+ const tuple = useEditableValue(value);
133
+ state = tuple[0];
134
+ dispatch = tuple[1];
135
+ return null;
136
+ }
137
+
138
+ const container = document.createElement('div');
139
+ ReactDOM.render(<Example value={1} />, container);
140
+ expect(state.editableValue).toEqual('1');
141
+ expect(state.externalValue).toEqual(1);
142
+ expect(state.parsedValue).toEqual(1);
143
+ expect(state.hasPendingChanges).toBe(false);
144
+ expect(state.isValid).toBe(true);
145
+
146
+ // Update (local) editable state.
147
+ act(() =>
148
+ dispatch({
149
+ type: 'UPDATE',
150
+ editableValue: '2',
151
+ externalValue: 1,
152
+ }),
153
+ );
154
+ expect(state.editableValue).toEqual('2');
155
+ expect(state.externalValue).toEqual(1);
156
+ expect(state.parsedValue).toEqual(2);
157
+ expect(state.hasPendingChanges).toBe(true);
158
+ expect(state.isValid).toBe(true);
159
+
160
+ // Reset editable state
161
+ act(() =>
162
+ dispatch({
163
+ type: 'RESET',
164
+ externalValue: 1,
165
+ }),
166
+ );
167
+ expect(state.editableValue).toEqual('1');
168
+ expect(state.externalValue).toEqual(1);
169
+ expect(state.parsedValue).toEqual(1);
170
+ expect(state.hasPendingChanges).toBe(false);
171
+ expect(state.isValid).toBe(true);
172
+ });
173
+});
packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js
+33
-33
@@ -7,7 +7,7 @@
7
* @flow
8
*/
9
10
-import React, {Fragment, useCallback, useRef} from 'react';
10
+import React, {Fragment, useRef} from 'react';
11
import Button from '../Button';
12
import ButtonIcon from '../ButtonIcon';
13
import styles from './EditableValue.css';
@@ -17,51 +17,51 @@ type OverrideValueFn = (path: Array<string | number>, value: any) => void;
17
18
type EditableValueProps = {|
19
className?: string,
20
- initialValue: any,
20
overrideValueFn: OverrideValueFn,
21
path: Array<string | number>,
22
+ value: any,
23
|};
24
25
export default function EditableValue({
26
className = '',
27
- initialValue,
27
overrideValueFn,
28
path,
29
+ value,
30
}: EditableValueProps) {
31
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;
34
41
- const handleChange = useCallback(({target}) => update(target.value), [
42
- update,
43
- ]);
35
+ const reset = () =>
36
+ dispatch({
37
+ type: 'RESET',
38
+ externalValue: value,
39
+ });
40
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
+ });
47
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
+ };
65
66
let placeholder = '';
67
if (editableValue === undefined) {
packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js
+1
-1
@@ -270,9 +270,9 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
270
</span>
271
{typeof overrideValueFn === 'function' ? (
272
<EditableValue
273
- initialValue={value}
273
overrideValueFn={overrideValueFn}
274
path={[]}
275
+ value={value}
276
/>
277
) : (
278
// $FlowFixMe Cannot create span element because in property children
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js
+1
-1
@@ -105,9 +105,9 @@ export default function InspectedElementTree({
105
:
106
<EditableValue
107
className={styles.EditableValue}
108
- initialValue={''}
108
overrideValueFn={handleNewEntryValue}
109
path={[newPropName]}
110
+ value={''}
111
/>
112
</div>
113
)}
packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js
+1
-1
@@ -102,9 +102,9 @@ export default function KeyValue({
102
</span>
103
{isEditable ? (
104
<EditableValue
105
- initialValue={value}
105
overrideValueFn={((overrideValueFn: any): OverrideValueFn)}
106
path={path}
107
+ value={value}
108
/>
109
) : (
110
<span className={styles.Value}>{displayValue}</span>
packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
+4
-1
@@ -619,6 +619,7 @@ type Props = {|
619
children: React$Node,
620
621
// Used for automated testing
622
+ defaultInspectedElementID?: ?number,
623
defaultOwnerID?: ?number,
624
defaultSelectedElementID?: ?number,
625
defaultSelectedElementIndex?: ?number,
@@ -627,6 +628,7 @@ type Props = {|
628
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
629
function TreeContextController({
630
children,
631
+ defaultInspectedElementID,
632
defaultOwnerID,
633
defaultSelectedElementID,
634
defaultSelectedElementIndex,
@@ -700,7 +702,8 @@ function TreeContextController({
702
ownerFlatTree: null,
703
704
// Inspection element panel
703
- inspectedElementID: null,
705
+ inspectedElementID:
706
+ defaultInspectedElementID == null ? null : defaultInspectedElementID,
707
});
708
709
const dispatchWrapper = useCallback(
packages/react-devtools-shared/src/devtools/views/hooks.js
+80
-46
@@ -8,68 +8,102 @@
8
*/
9
10
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';
18
import {
19
localStorageGetItem,
20
localStorageSetItem,
21
} from 'react-devtools-shared/src/storage';
22
import {sanitizeForParse, smartParse, smartStringify} from '../utils';
23
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 = {|
37
editableValue: any,
38
+ externalValue: any,
39
hasPendingChanges: boolean,
40
isValid: boolean,
41
parsedValue: any,
24
- reset: () => void,
25
- update: (newValue: any) => void,
42
|};
43
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
+
76
// Convenience hook for working with an editable value that is validated via JSON.parse.
77
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
+ });
90
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
+ }
105
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];
107
}
108
109
export function useIsOverflowing(
packages/react-devtools/CHANGELOG.md
+2
@@ -7,9 +7,11 @@
7
</summary>
8
9
<!-- Upcoming changes go here -->
10
+
11
#### Bug fixes
12
* Fixed bug where Components panel was always empty for certain users. ([bvaughn](https://github.com/bvaughn) in [#16864](https://github.com/facebook/react/pull/16864))
13
* Fixed regression in DevTools editable hooks interface that caused primitive values to be shown as `undefined`. ([bvaughn](https://github.com/bvaughn) in [#16867](https://github.com/facebook/react/pull/16867))
14
+* Fixed bug where DevTools showed stale values in props/state/hooks editing interface. ([bvaughn](https://github.com/bvaughn) in [#16878](https://github.com/facebook/react/pull/16878))
15
</details>
16
17
## 4.1.0 (September 19, 2019)