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

412 lines
12 KiB
JavaScript
Raw Normal View History

2019-08-28 01:54:01 +08:00
/**
* 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
*/
import * as React from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
2019-08-14 08:58:03 +08:00
import {unstable_batchedUpdates as batchedUpdates} from 'react-dom';
import {createResource} from '../../cache';
import {BridgeContext, StoreContext} from '../context';
import {hydrate, fillInPath} from 'react-devtools-shared/src/hydration';
import {TreeStateContext} from './TreeContext';
import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils';
import type {
InspectedElement as InspectedElementBackend,
InspectedElementPayload,
2019-08-14 06:59:43 +08:00
} from 'react-devtools-shared/src/backend/types';
import type {
DehydratedData,
Element,
InspectedElement as InspectedElementFrontend,
2019-08-14 06:59:43 +08:00
} from 'react-devtools-shared/src/devtools/views/Components/types';
2019-08-14 08:58:03 +08:00
import type {Resource, Thenable} from '../../cache';
export type StoreAsGlobal = (id: number, path: Array<string | number>) => void;
export type CopyInspectedElementPath = (
id: number,
path: Array<string | number>,
) => void;
export type GetInspectedElementPath = (
id: number,
2019-08-14 08:58:03 +08:00
path: Array<string | number>,
) => void;
export type GetInspectedElement = (
2019-08-14 08:58:03 +08:00
id: number,
) => InspectedElementFrontend | null;
type RefreshInspectedElement = () => void;
export type InspectedElementContextType = {|
copyInspectedElementPath: CopyInspectedElementPath,
getInspectedElementPath: GetInspectedElementPath,
getInspectedElement: GetInspectedElement,
refreshInspectedElement: RefreshInspectedElement,
storeAsGlobal: StoreAsGlobal,
|};
const InspectedElementContext = createContext<InspectedElementContextType>(
((null: any): InspectedElementContextType),
);
InspectedElementContext.displayName = 'InspectedElementContext';
type ResolveFn = (inspectedElement: InspectedElementFrontend) => void;
type InProgressRequest = {|
promise: Thenable<InspectedElementFrontend>,
resolveFn: ResolveFn,
|};
const inProgressRequests: WeakMap<Element, InProgressRequest> = new WeakMap();
const resource: Resource<
Element,
Element,
2019-08-14 08:58:03 +08:00
InspectedElementFrontend,
> = createResource(
(element: Element) => {
const request = inProgressRequests.get(element);
2019-04-23 06:18:32 +08:00
if (request != null) {
return request.promise;
}
let resolveFn = ((null: any): ResolveFn);
const promise = new Promise(resolve => {
resolveFn = resolve;
});
2019-08-14 08:58:03 +08:00
inProgressRequests.set(element, {promise, resolveFn});
2019-04-23 06:18:32 +08:00
return promise;
},
(element: Element) => element,
2019-08-14 08:58:03 +08:00
{useWeakMap: true},
2019-04-23 06:18:32 +08:00
);
type Props = {|
children: React$Node,
|};
2019-08-14 08:58:03 +08:00
function InspectedElementContextController({children}: Props) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const storeAsGlobalCount = useRef(1);
// Ask the backend to store the value at the specified path as a global variable.
const storeAsGlobal = useCallback<GetInspectedElementPath>(
(id: number, path: Array<string | number>) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('storeAsGlobal', {
count: storeAsGlobalCount.current++,
id,
path,
rendererID,
});
}
},
[bridge, store],
);
// Ask the backend to copy the specified path to the clipboard.
const copyInspectedElementPath = useCallback<GetInspectedElementPath>(
(id: number, path: Array<string | number>) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('copyElementPath', {id, path, rendererID});
}
},
[bridge, store],
);
// Ask the backend to fill in a "dehydrated" path; this will result in a "inspectedElement".
const getInspectedElementPath = useCallback<GetInspectedElementPath>(
(id: number, path: Array<string | number>) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
2019-08-14 08:58:03 +08:00
bridge.send('inspectElement', {id, path, rendererID});
}
},
2019-08-14 08:58:03 +08:00
[bridge, store],
);
const getInspectedElement = useCallback<GetInspectedElement>(
(id: number) => {
const element = store.getElementByID(id);
if (element !== null) {
return resource.read(element);
} else {
return null;
}
},
2019-08-14 08:58:03 +08:00
[store],
);
// It's very important that this context consumes selectedElementID and not inspectedElementID.
// Otherwise the effect that sends the "inspect" message across the bridge-
// would itself be blocked by the same render that suspends (waiting for the data).
2019-08-14 08:58:03 +08:00
const {selectedElementID} = useContext(TreeStateContext);
const refreshInspectedElement = useCallback<RefreshInspectedElement>(() => {
if (selectedElementID !== null) {
const rendererID = store.getRendererIDForElement(selectedElementID);
if (rendererID !== null) {
bridge.send('inspectElement', {id: selectedElementID, rendererID});
}
}
}, [bridge, selectedElementID]);
const [
currentlyInspectedElement,
setCurrentlyInspectedElement,
] = useState<InspectedElementFrontend | null>(null);
// This effect handler invalidates the suspense cache and schedules rendering updates with React.
useEffect(() => {
const onInspectedElement = (data: InspectedElementPayload) => {
const {id} = data;
let element;
switch (data.type) {
case 'no-change':
case 'not-found':
// No-op
break;
case 'hydrated-path':
// Merge new data into previous object and invalidate cache
element = store.getElementByID(id);
if (element !== null) {
if (currentlyInspectedElement != null) {
const value = hydrateHelper(data.value, data.path);
const inspectedElement = {...currentlyInspectedElement};
fillInPath(inspectedElement, data.value, data.path, value);
resource.write(element, inspectedElement);
2020-06-16 07:59:44 +08:00
// Schedule update with React if the currently-selected element has been invalidated.
if (id === selectedElementID) {
setCurrentlyInspectedElement(inspectedElement);
2019-08-14 08:58:03 +08:00
}
}
}
break;
case 'full-data':
const {
canEditFunctionProps,
Improve DevTools editing interface (#19774) * Improve DevTools editing interface This commit adds the ability to rename or delete keys in the props/state/hooks/context editor and adds tests to cover this functionality. DevTools will degrade gracefully for older versions of React that do not inject the new reconciler rename* or delete* methods. Specifically, this commit includes the following changes: * Adds unit tests (for modern and legacy renderers) to cover overriding props, renaming keys, and deleting keys. * Refactor backend override methods to reduce redundant Bridge/Agent listeners and methods. * Inject new (DEV-only) methods from reconciler into DevTools to rename and delete paths. * Refactor 'inspected element' UI components to improve readability. * Improve auto-size input to better mimic Chrome's Style editor panel. (See this Code Sandbox for a proof of concept.) It also contains the following code cleanup: * Additional unit tests have been added for modifying values as well as renaming or deleting paths. * Four new DEV-only methods have been added to the reconciler to be injected into the DevTools hook: overrideHookStateDeletePath, overrideHookStateRenamePath, overridePropsDeletePath, and overridePropsRenamePath. (DevTools will degrade gracefully for older renderers without these methods.) * I also took this as an opportunity to refactor some of the existing code in a few places: * Rather than the backend implementing separate methods for editing props, state, hooks, and context– there are now three methods: deletePath, renamePath, and overrideValueAtPath that accept a type argument to differentiate between props, state, context, or hooks. * The various UI components for the DevTools frontend have been refactored to remove some unnecessary repetition. This commit also adds temporary support for override* commands with mismatched backend/frontend versions: * Add message forwarding for older backend methods (overrideContext, overrideHookState, overrideProps, and overrideState) to the new overrideValueAtPath method. This was done in both the frontend Bridge (for newer frontends passing messages to older embedded backends) and in the backend Agent (for older frontends passing messages to newer backends). We do this because React Native embeds the React DevTools backend, but cannot control which version of the frontend users use. * Additional unit tests have been added as well to cover the older frontend to newer backend case. Our DevTools test infra does not make it easy to write tests for the other way around.
2020-09-18 23:07:18 +08:00
canEditFunctionPropsDeletePaths,
canEditFunctionPropsRenamePaths,
canEditHooks,
Improve DevTools editing interface (#19774) * Improve DevTools editing interface This commit adds the ability to rename or delete keys in the props/state/hooks/context editor and adds tests to cover this functionality. DevTools will degrade gracefully for older versions of React that do not inject the new reconciler rename* or delete* methods. Specifically, this commit includes the following changes: * Adds unit tests (for modern and legacy renderers) to cover overriding props, renaming keys, and deleting keys. * Refactor backend override methods to reduce redundant Bridge/Agent listeners and methods. * Inject new (DEV-only) methods from reconciler into DevTools to rename and delete paths. * Refactor 'inspected element' UI components to improve readability. * Improve auto-size input to better mimic Chrome's Style editor panel. (See this Code Sandbox for a proof of concept.) It also contains the following code cleanup: * Additional unit tests have been added for modifying values as well as renaming or deleting paths. * Four new DEV-only methods have been added to the reconciler to be injected into the DevTools hook: overrideHookStateDeletePath, overrideHookStateRenamePath, overridePropsDeletePath, and overridePropsRenamePath. (DevTools will degrade gracefully for older renderers without these methods.) * I also took this as an opportunity to refactor some of the existing code in a few places: * Rather than the backend implementing separate methods for editing props, state, hooks, and context– there are now three methods: deletePath, renamePath, and overrideValueAtPath that accept a type argument to differentiate between props, state, context, or hooks. * The various UI components for the DevTools frontend have been refactored to remove some unnecessary repetition. This commit also adds temporary support for override* commands with mismatched backend/frontend versions: * Add message forwarding for older backend methods (overrideContext, overrideHookState, overrideProps, and overrideState) to the new overrideValueAtPath method. This was done in both the frontend Bridge (for newer frontends passing messages to older embedded backends) and in the backend Agent (for older frontends passing messages to newer backends). We do this because React Native embeds the React DevTools backend, but cannot control which version of the frontend users use. * Additional unit tests have been added as well to cover the older frontend to newer backend case. Our DevTools test infra does not make it easy to write tests for the other way around.
2020-09-18 23:07:18 +08:00
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleSuspense,
canViewSource,
hasLegacyContext,
source,
type,
owners,
context,
hooks,
props,
rendererPackageName,
rendererVersion,
rootType,
state,
key,
errors,
warnings,
} = ((data.value: any): InspectedElementBackend);
const inspectedElement: InspectedElementFrontend = {
canEditFunctionProps,
Improve DevTools editing interface (#19774) * Improve DevTools editing interface This commit adds the ability to rename or delete keys in the props/state/hooks/context editor and adds tests to cover this functionality. DevTools will degrade gracefully for older versions of React that do not inject the new reconciler rename* or delete* methods. Specifically, this commit includes the following changes: * Adds unit tests (for modern and legacy renderers) to cover overriding props, renaming keys, and deleting keys. * Refactor backend override methods to reduce redundant Bridge/Agent listeners and methods. * Inject new (DEV-only) methods from reconciler into DevTools to rename and delete paths. * Refactor 'inspected element' UI components to improve readability. * Improve auto-size input to better mimic Chrome's Style editor panel. (See this Code Sandbox for a proof of concept.) It also contains the following code cleanup: * Additional unit tests have been added for modifying values as well as renaming or deleting paths. * Four new DEV-only methods have been added to the reconciler to be injected into the DevTools hook: overrideHookStateDeletePath, overrideHookStateRenamePath, overridePropsDeletePath, and overridePropsRenamePath. (DevTools will degrade gracefully for older renderers without these methods.) * I also took this as an opportunity to refactor some of the existing code in a few places: * Rather than the backend implementing separate methods for editing props, state, hooks, and context– there are now three methods: deletePath, renamePath, and overrideValueAtPath that accept a type argument to differentiate between props, state, context, or hooks. * The various UI components for the DevTools frontend have been refactored to remove some unnecessary repetition. This commit also adds temporary support for override* commands with mismatched backend/frontend versions: * Add message forwarding for older backend methods (overrideContext, overrideHookState, overrideProps, and overrideState) to the new overrideValueAtPath method. This was done in both the frontend Bridge (for newer frontends passing messages to older embedded backends) and in the backend Agent (for older frontends passing messages to newer backends). We do this because React Native embeds the React DevTools backend, but cannot control which version of the frontend users use. * Additional unit tests have been added as well to cover the older frontend to newer backend case. Our DevTools test infra does not make it easy to write tests for the other way around.
2020-09-18 23:07:18 +08:00
canEditFunctionPropsDeletePaths,
canEditFunctionPropsRenamePaths,
canEditHooks,
Improve DevTools editing interface (#19774) * Improve DevTools editing interface This commit adds the ability to rename or delete keys in the props/state/hooks/context editor and adds tests to cover this functionality. DevTools will degrade gracefully for older versions of React that do not inject the new reconciler rename* or delete* methods. Specifically, this commit includes the following changes: * Adds unit tests (for modern and legacy renderers) to cover overriding props, renaming keys, and deleting keys. * Refactor backend override methods to reduce redundant Bridge/Agent listeners and methods. * Inject new (DEV-only) methods from reconciler into DevTools to rename and delete paths. * Refactor 'inspected element' UI components to improve readability. * Improve auto-size input to better mimic Chrome's Style editor panel. (See this Code Sandbox for a proof of concept.) It also contains the following code cleanup: * Additional unit tests have been added for modifying values as well as renaming or deleting paths. * Four new DEV-only methods have been added to the reconciler to be injected into the DevTools hook: overrideHookStateDeletePath, overrideHookStateRenamePath, overridePropsDeletePath, and overridePropsRenamePath. (DevTools will degrade gracefully for older renderers without these methods.) * I also took this as an opportunity to refactor some of the existing code in a few places: * Rather than the backend implementing separate methods for editing props, state, hooks, and context– there are now three methods: deletePath, renamePath, and overrideValueAtPath that accept a type argument to differentiate between props, state, context, or hooks. * The various UI components for the DevTools frontend have been refactored to remove some unnecessary repetition. This commit also adds temporary support for override* commands with mismatched backend/frontend versions: * Add message forwarding for older backend methods (overrideContext, overrideHookState, overrideProps, and overrideState) to the new overrideValueAtPath method. This was done in both the frontend Bridge (for newer frontends passing messages to older embedded backends) and in the backend Agent (for older frontends passing messages to newer backends). We do this because React Native embeds the React DevTools backend, but cannot control which version of the frontend users use. * Additional unit tests have been added as well to cover the older frontend to newer backend case. Our DevTools test infra does not make it easy to write tests for the other way around.
2020-09-18 23:07:18 +08:00
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleSuspense,
canViewSource,
hasLegacyContext,
id,
key,
rendererPackageName,
rendererVersion,
rootType,
source,
type,
owners:
owners === null
? null
: owners.map(owner => {
const [
displayName,
hocDisplayNames,
] = separateDisplayNameAndHOCs(
owner.displayName,
owner.type,
);
return {
...owner,
displayName,
hocDisplayNames,
};
}),
context: hydrateHelper(context),
hooks: hydrateHelper(hooks),
props: hydrateHelper(props),
state: hydrateHelper(state),
errors,
warnings,
};
element = store.getElementByID(id);
if (element !== null) {
const request = inProgressRequests.get(element);
if (request != null) {
inProgressRequests.delete(element);
batchedUpdates(() => {
request.resolveFn(inspectedElement);
setCurrentlyInspectedElement(inspectedElement);
});
} else {
resource.write(element, inspectedElement);
2020-06-16 07:59:44 +08:00
// Schedule update with React if the currently-selected element has been invalidated.
if (id === selectedElementID) {
setCurrentlyInspectedElement(inspectedElement);
2019-08-14 08:58:03 +08:00
}
2019-06-18 22:41:30 +08:00
}
}
break;
default:
break;
}
};
2019-08-14 08:58:03 +08:00
bridge.addListener('inspectedElement', onInspectedElement);
return () => bridge.removeListener('inspectedElement', onInspectedElement);
}, [bridge, currentlyInspectedElement, selectedElementID, store]);
2019-08-14 08:58:03 +08:00
// This effect handler polls for updates on the currently selected element.
useEffect(() => {
if (selectedElementID === null) {
return () => {};
}
const rendererID = store.getRendererIDForElement(selectedElementID);
let timeoutID: TimeoutID | null = null;
const sendRequest = () => {
timeoutID = null;
2019-08-14 08:58:03 +08:00
if (rendererID !== null) {
bridge.send('inspectElement', {id: selectedElementID, rendererID});
}
};
// Send the initial inspection request.
// We'll poll for an update in the response handler below.
sendRequest();
const onInspectedElement = (data: InspectedElementPayload) => {
// If this is the element we requested, wait a little bit and then ask for another update.
if (data.id === selectedElementID) {
switch (data.type) {
case 'no-change':
case 'full-data':
case 'hydrated-path':
if (timeoutID !== null) {
clearTimeout(timeoutID);
}
timeoutID = setTimeout(sendRequest, 1000);
break;
default:
break;
2019-08-14 08:58:03 +08:00
}
}
};
2019-08-14 08:58:03 +08:00
bridge.addListener('inspectedElement', onInspectedElement);
2019-08-14 08:58:03 +08:00
return () => {
bridge.removeListener('inspectedElement', onInspectedElement);
2019-08-14 08:58:03 +08:00
if (timeoutID !== null) {
clearTimeout(timeoutID);
}
};
}, [bridge, selectedElementID, store]);
const value = useMemo(
() => ({
copyInspectedElementPath,
getInspectedElement,
getInspectedElementPath,
refreshInspectedElement,
storeAsGlobal,
}),
// InspectedElement is used to invalidate the cache and schedule an update with React.
[
copyInspectedElementPath,
currentlyInspectedElement,
getInspectedElement,
getInspectedElementPath,
refreshInspectedElement,
storeAsGlobal,
],
);
return (
<InspectedElementContext.Provider value={value}>
{children}
</InspectedElementContext.Provider>
);
}
function hydrateHelper(
dehydratedData: DehydratedData | null,
2019-08-14 08:58:03 +08:00
path?: Array<string | number>,
): Object | null {
if (dehydratedData !== null) {
const {cleaned, data, unserializable} = dehydratedData;
if (path) {
2019-08-14 08:58:03 +08:00
const {length} = path;
if (length > 0) {
// Hydration helper requires full paths, but inspection dehydrates with relative paths.
// In that event it's important that we adjust the "cleaned" paths to match.
return hydrate(
data,
cleaned.map(cleanedPath => cleanedPath.slice(length)),
unserializable.map(unserializablePath =>
unserializablePath.slice(length),
),
);
}
}
return hydrate(data, cleaned, unserializable);
} else {
return null;
}
}
2019-08-14 08:58:03 +08:00
export {InspectedElementContext, InspectedElementContextController};