Skip to content

Commit

Permalink
Update useEditableValue hook to sync external value changes
Browse files Browse the repository at this point in the history
  • Loading branch information
Brian Vaughn committed Sep 24, 2019
1 parent 911104a commit 52c17d5
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 63 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This is a custom Jest transformer turning style imports into empty objects.

module.exports = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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="foo" />, container);
expect(state.editableValue).toEqual('"foo"');
expect(state.externalValue).toEqual('foo');
expect(state.hasPendingChanges).toBe(false);

// If there are NO pending changes,
// an update to the external prop value should override the local/pending value.
ReactDOM.render(<Example value="bar" />, container);
expect(state.editableValue).toEqual('"bar"');
expect(state.externalValue).toEqual('bar');
expect(state.hasPendingChanges).toBe(false);
});

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="foo" />, container);
expect(state.editableValue).toEqual('"foo"');
expect(state.externalValue).toEqual('foo');
expect(state.hasPendingChanges).toBe(false);

// Update (local) editable state.
act(() =>
dispatch({
type: 'UPDATE',
editableValue: 'not-foo',
externalValue: 'foo',
}),
);
expect(state.editableValue).toEqual('not-foo');
expect(state.externalValue).toEqual('foo');
expect(state.hasPendingChanges).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="bar" />, container);
expect(state.editableValue).toEqual('not-foo');
expect(state.externalValue).toEqual('bar');
expect(state.hasPendingChanges).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,33 @@ 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 handleChange = ({target}) =>
dispatch({
type: 'UPDATE',
editableValue: target.value,
externalValue: value,
});

const handleKeyDown = useCallback(
event => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ export default function InspectedElementTree({
:&nbsp;
<EditableValue
className={styles.EditableValue}
initialValue={''}
overrideValueFn={handleNewEntryValue}
path={[newPropName]}
value={''}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type Props = {||};

export default function SelectedElement(_: Props) {
const {inspectedElementID} = useContext(TreeStateContext);
console.log('<SelectedElement> inspectedElementID', inspectedElementID);
const dispatch = useContext(TreeDispatcherContext);
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
Expand All @@ -59,6 +60,7 @@ export default function SelectedElement(_: Props) {

const inspectedElement =
inspectedElementID != null ? getInspectedElement(inspectedElementID) : null;
console.log('<SelectedElement> inspectedElement', inspectedElement);

const highlightElement = useCallback(
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ type Props = {|
children: React$Node,

// Used for automated testing
defaultInspectedElementID?: ?number,
defaultOwnerID?: ?number,
defaultSelectedElementID?: ?number,
defaultSelectedElementIndex?: ?number,
Expand All @@ -627,6 +628,7 @@ type Props = {|
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
function TreeContextController({
children,
defaultInspectedElementID,
defaultOwnerID,
defaultSelectedElementID,
defaultSelectedElementIndex,
Expand Down Expand Up @@ -700,7 +702,8 @@ function TreeContextController({
ownerFlatTree: null,

// Inspection element panel
inspectedElementID: null,
inspectedElementID:
defaultInspectedElementID == null ? null : defaultInspectedElementID,
});

const dispatchWrapper = useCallback(
Expand Down
126 changes: 80 additions & 46 deletions packages/react-devtools-shared/src/devtools/views/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit 52c17d5

Please sign in to comment.