mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
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
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
describe('useEditableValue', () => {
|
||||
let act;
|
||||
let React;
|
||||
let ReactDOM;
|
||||
let useEditableValue;
|
||||
|
||||
beforeEach(() => {
|
||||
const utils = require('./utils');
|
||||
act = utils.act;
|
||||
|
||||
React = require('react');
|
||||
ReactDOM = require('react-dom');
|
||||
|
||||
useEditableValue = require('../devtools/views/hooks').useEditableValue;
|
||||
});
|
||||
|
||||
it('should override editable state when external props are updated', () => {
|
||||
let state;
|
||||
|
||||
function Example({value}) {
|
||||
const tuple = useEditableValue(value);
|
||||
state = tuple[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
ReactDOM.render(<Example value={1} />, container);
|
||||
expect(state.editableValue).toEqual('1');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// If there are NO pending changes,
|
||||
// an update to the external prop value should override the local/pending value.
|
||||
ReactDOM.render(<Example value={2} />, container);
|
||||
expect(state.editableValue).toEqual('2');
|
||||
expect(state.externalValue).toEqual(2);
|
||||
expect(state.parsedValue).toEqual(2);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should not override editable state when external props are updated if there are pending changes', () => {
|
||||
let dispatch, state;
|
||||
|
||||
function Example({value}) {
|
||||
const tuple = useEditableValue(value);
|
||||
state = tuple[0];
|
||||
dispatch = tuple[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
ReactDOM.render(<Example value={1} />, container);
|
||||
expect(state.editableValue).toEqual('1');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// Update (local) editable state.
|
||||
act(() =>
|
||||
dispatch({
|
||||
type: 'UPDATE',
|
||||
editableValue: '2',
|
||||
externalValue: 1,
|
||||
}),
|
||||
);
|
||||
expect(state.editableValue).toEqual('2');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(2);
|
||||
expect(state.hasPendingChanges).toBe(true);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// If there ARE pending changes,
|
||||
// an update to the external prop value should NOT override the local/pending value.
|
||||
ReactDOM.render(<Example value={3} />, container);
|
||||
expect(state.editableValue).toEqual('2');
|
||||
expect(state.externalValue).toEqual(3);
|
||||
expect(state.parsedValue).toEqual(2);
|
||||
expect(state.hasPendingChanges).toBe(true);
|
||||
expect(state.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse edits to ensure valid JSON', () => {
|
||||
let dispatch, state;
|
||||
|
||||
function Example({value}) {
|
||||
const tuple = useEditableValue(value);
|
||||
state = tuple[0];
|
||||
dispatch = tuple[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
ReactDOM.render(<Example value={1} />, container);
|
||||
expect(state.editableValue).toEqual('1');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// Update (local) editable state.
|
||||
act(() =>
|
||||
dispatch({
|
||||
type: 'UPDATE',
|
||||
editableValue: '"a',
|
||||
externalValue: 1,
|
||||
}),
|
||||
);
|
||||
expect(state.editableValue).toEqual('"a');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(true);
|
||||
expect(state.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset to external value upon request', () => {
|
||||
let dispatch, state;
|
||||
|
||||
function Example({value}) {
|
||||
const tuple = useEditableValue(value);
|
||||
state = tuple[0];
|
||||
dispatch = tuple[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
ReactDOM.render(<Example value={1} />, container);
|
||||
expect(state.editableValue).toEqual('1');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// Update (local) editable state.
|
||||
act(() =>
|
||||
dispatch({
|
||||
type: 'UPDATE',
|
||||
editableValue: '2',
|
||||
externalValue: 1,
|
||||
}),
|
||||
);
|
||||
expect(state.editableValue).toEqual('2');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(2);
|
||||
expect(state.hasPendingChanges).toBe(true);
|
||||
expect(state.isValid).toBe(true);
|
||||
|
||||
// Reset editable state
|
||||
act(() =>
|
||||
dispatch({
|
||||
type: 'RESET',
|
||||
externalValue: 1,
|
||||
}),
|
||||
);
|
||||
expect(state.editableValue).toEqual('1');
|
||||
expect(state.externalValue).toEqual(1);
|
||||
expect(state.parsedValue).toEqual(1);
|
||||
expect(state.hasPendingChanges).toBe(false);
|
||||
expect(state.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
+33
-33
@@ -7,7 +7,7 @@
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import React, {Fragment, useCallback, useRef} from 'react';
|
||||
import React, {Fragment, useRef} from 'react';
|
||||
import Button from '../Button';
|
||||
import ButtonIcon from '../ButtonIcon';
|
||||
import styles from './EditableValue.css';
|
||||
@@ -17,51 +17,51 @@ type OverrideValueFn = (path: Array<string | number>, value: any) => void;
|
||||
|
||||
type EditableValueProps = {|
|
||||
className?: string,
|
||||
initialValue: any,
|
||||
overrideValueFn: OverrideValueFn,
|
||||
path: Array<string | number>,
|
||||
value: any,
|
||||
|};
|
||||
|
||||
export default function EditableValue({
|
||||
className = '',
|
||||
initialValue,
|
||||
overrideValueFn,
|
||||
path,
|
||||
value,
|
||||
}: EditableValueProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const {
|
||||
editableValue,
|
||||
hasPendingChanges,
|
||||
isValid,
|
||||
parsedValue,
|
||||
reset,
|
||||
update,
|
||||
} = useEditableValue(initialValue);
|
||||
const [state, dispatch] = useEditableValue(value);
|
||||
const {editableValue, hasPendingChanges, isValid, parsedValue} = state;
|
||||
|
||||
const handleChange = useCallback(({target}) => update(target.value), [
|
||||
update,
|
||||
]);
|
||||
const reset = () =>
|
||||
dispatch({
|
||||
type: 'RESET',
|
||||
externalValue: value,
|
||||
});
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
event => {
|
||||
// Prevent keydown events from e.g. change selected element in the tree
|
||||
event.stopPropagation();
|
||||
const handleChange = ({target}) =>
|
||||
dispatch({
|
||||
type: 'UPDATE',
|
||||
editableValue: target.value,
|
||||
externalValue: value,
|
||||
});
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (isValid && hasPendingChanges) {
|
||||
overrideValueFn(path, parsedValue);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
reset();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[hasPendingChanges, isValid, overrideValueFn, parsedValue, reset],
|
||||
);
|
||||
const handleKeyDown = event => {
|
||||
// Prevent keydown events from e.g. change selected element in the tree
|
||||
event.stopPropagation();
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (isValid && hasPendingChanges) {
|
||||
overrideValueFn(path, parsedValue);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
reset();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let placeholder = '';
|
||||
if (editableValue === undefined) {
|
||||
|
||||
@@ -270,9 +270,9 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {
|
||||
</span>
|
||||
{typeof overrideValueFn === 'function' ? (
|
||||
<EditableValue
|
||||
initialValue={value}
|
||||
overrideValueFn={overrideValueFn}
|
||||
path={[]}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
// $FlowFixMe Cannot create span element because in property children
|
||||
|
||||
+1
-1
@@ -105,9 +105,9 @@ export default function InspectedElementTree({
|
||||
:
|
||||
<EditableValue
|
||||
className={styles.EditableValue}
|
||||
initialValue={''}
|
||||
overrideValueFn={handleNewEntryValue}
|
||||
path={[newPropName]}
|
||||
value={''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -102,9 +102,9 @@ export default function KeyValue({
|
||||
</span>
|
||||
{isEditable ? (
|
||||
<EditableValue
|
||||
initialValue={value}
|
||||
overrideValueFn={((overrideValueFn: any): OverrideValueFn)}
|
||||
path={path}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.Value}>{displayValue}</span>
|
||||
|
||||
+4
-1
@@ -619,6 +619,7 @@ type Props = {|
|
||||
children: React$Node,
|
||||
|
||||
// Used for automated testing
|
||||
defaultInspectedElementID?: ?number,
|
||||
defaultOwnerID?: ?number,
|
||||
defaultSelectedElementID?: ?number,
|
||||
defaultSelectedElementIndex?: ?number,
|
||||
@@ -627,6 +628,7 @@ type Props = {|
|
||||
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
|
||||
function TreeContextController({
|
||||
children,
|
||||
defaultInspectedElementID,
|
||||
defaultOwnerID,
|
||||
defaultSelectedElementID,
|
||||
defaultSelectedElementIndex,
|
||||
@@ -700,7 +702,8 @@ function TreeContextController({
|
||||
ownerFlatTree: null,
|
||||
|
||||
// Inspection element panel
|
||||
inspectedElementID: null,
|
||||
inspectedElementID:
|
||||
defaultInspectedElementID == null ? null : defaultInspectedElementID,
|
||||
});
|
||||
|
||||
const dispatchWrapper = useCallback(
|
||||
|
||||
+80
-46
@@ -8,68 +8,102 @@
|
||||
*/
|
||||
|
||||
import throttle from 'lodash.throttle';
|
||||
import {useCallback, useEffect, useLayoutEffect, useState} from 'react';
|
||||
import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
localStorageGetItem,
|
||||
localStorageSetItem,
|
||||
} from 'react-devtools-shared/src/storage';
|
||||
import {sanitizeForParse, smartParse, smartStringify} from '../utils';
|
||||
|
||||
type EditableValue = {|
|
||||
type ACTION_RESET = {|
|
||||
type: 'RESET',
|
||||
externalValue: any,
|
||||
|};
|
||||
type ACTION_UPDATE = {|
|
||||
type: 'UPDATE',
|
||||
editableValue: any,
|
||||
externalValue: any,
|
||||
|};
|
||||
|
||||
type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE;
|
||||
type UseEditableValueDispatch = (action: UseEditableValueAction) => void;
|
||||
type UseEditableValueState = {|
|
||||
editableValue: any,
|
||||
externalValue: any,
|
||||
hasPendingChanges: boolean,
|
||||
isValid: boolean,
|
||||
parsedValue: any,
|
||||
reset: () => void,
|
||||
update: (newValue: any) => void,
|
||||
|};
|
||||
|
||||
function useEditableValueReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return {
|
||||
...state,
|
||||
editableValue: smartStringify(action.externalValue),
|
||||
externalValue: action.externalValue,
|
||||
hasPendingChanges: false,
|
||||
isValid: true,
|
||||
parsedValue: action.externalValue,
|
||||
};
|
||||
case 'UPDATE':
|
||||
let isNewValueValid = false;
|
||||
let newParsedValue;
|
||||
try {
|
||||
newParsedValue = smartParse(action.editableValue);
|
||||
isNewValueValid = true;
|
||||
} catch (error) {}
|
||||
return {
|
||||
...state,
|
||||
editableValue: sanitizeForParse(action.editableValue),
|
||||
externalValue: action.externalValue,
|
||||
hasPendingChanges:
|
||||
smartStringify(action.externalValue) !== action.editableValue,
|
||||
isValid: isNewValueValid,
|
||||
parsedValue: isNewValueValid ? newParsedValue : state.parsedValue,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Invalid action "${action.type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience hook for working with an editable value that is validated via JSON.parse.
|
||||
export function useEditableValue(
|
||||
initialValue: any,
|
||||
initialIsValid?: boolean = true,
|
||||
): EditableValue {
|
||||
const [editableValue, setEditableValue] = useState(() =>
|
||||
smartStringify(initialValue),
|
||||
);
|
||||
const [parsedValue, setParsedValue] = useState(initialValue);
|
||||
const [isValid, setIsValid] = useState(initialIsValid);
|
||||
externalValue: any,
|
||||
): [UseEditableValueState, UseEditableValueDispatch] {
|
||||
const [state, dispatch] = useReducer<
|
||||
UseEditableValueState,
|
||||
UseEditableValueAction,
|
||||
>(useEditableValueReducer, {
|
||||
editableValue: smartStringify(externalValue),
|
||||
externalValue,
|
||||
hasPendingChanges: false,
|
||||
isValid: true,
|
||||
parsedValue: externalValue,
|
||||
});
|
||||
|
||||
const reset = useCallback(
|
||||
() => {
|
||||
setEditableValue(smartStringify(initialValue));
|
||||
setParsedValue(initialValue);
|
||||
setIsValid(initialIsValid);
|
||||
},
|
||||
[initialValue, initialIsValid],
|
||||
);
|
||||
if (state.externalValue !== externalValue) {
|
||||
if (!state.hasPendingChanges) {
|
||||
dispatch({
|
||||
type: 'RESET',
|
||||
externalValue,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'UPDATE',
|
||||
editableValue: state.editableValue,
|
||||
externalValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const update = useCallback(newValue => {
|
||||
let isNewValueValid = false;
|
||||
let newParsedValue;
|
||||
try {
|
||||
newParsedValue = smartParse(newValue);
|
||||
isNewValueValid = true;
|
||||
} catch (error) {}
|
||||
|
||||
batchedUpdates(() => {
|
||||
setEditableValue(sanitizeForParse(newValue));
|
||||
if (isNewValueValid) {
|
||||
setParsedValue(newParsedValue);
|
||||
}
|
||||
setIsValid(isNewValueValid);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editableValue,
|
||||
hasPendingChanges: smartStringify(initialValue) !== editableValue,
|
||||
isValid,
|
||||
parsedValue,
|
||||
reset,
|
||||
update,
|
||||
};
|
||||
return [state, dispatch];
|
||||
}
|
||||
|
||||
export function useIsOverflowing(
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
</summary>
|
||||
|
||||
<!-- Upcoming changes go here -->
|
||||
|
||||
#### Bug fixes
|
||||
* 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))
|
||||
* 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))
|
||||
* 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))
|
||||
</details>
|
||||
|
||||
## 4.1.0 (September 19, 2019)
|
||||
|
||||
Reference in New Issue
Block a user