@samitouri / QOS-React-2 / commits / fa1a326227

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 :&nbsp;
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)