From b56bc1a62604c34b1e758f2a68c03b770c1ef6e3 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 4 Feb 2019 17:49:30 +0000 Subject: [PATCH] Initial pass at properties panel. Lots of TODOs remaining. --- package.json | 1 + shells/dev/app/App.js | 4 +- .../InspectableElements/FunctionWithState.js | 16 + .../InspectableElements.js | 14 + .../app/InspectableElements/NestedProps.js | 32 + shells/dev/app/InspectableElements/index.js | 5 + src/backend/ReactDebugHooks.js | 573 ++++++++++++++++++ src/backend/agent.js | 23 +- src/backend/index.js | 45 +- src/backend/renderer.js | 283 ++++++++- src/backend/types.js | 29 +- src/backend/utils.js | 46 +- src/devtools/store.js | 73 ++- src/devtools/types.js | 14 +- src/devtools/views/Element.css | 8 +- src/devtools/views/Element.js | 13 +- src/devtools/views/Elements.css | 1 - src/devtools/views/Elements.js | 40 +- src/devtools/views/HooksTree.css | 10 + src/devtools/views/HooksTree.js | 24 + src/devtools/views/Icon.css | 5 + src/devtools/views/Icon.js | 35 ++ src/devtools/views/InspectedElementTree.css | 20 + src/devtools/views/InspectedElementTree.js | 126 ++++ src/devtools/views/SelectedElement.css | 36 +- src/devtools/views/SelectedElement.js | 156 ++++- src/devtools/views/SelectedElementContext.js | 19 +- src/devtools/views/Tree.js | 48 +- src/devtools/views/context.js | 5 + src/devtools/views/root.css | 7 +- src/hook.js | 22 +- src/hydration.js | 216 +++++++ src/utils.js | 6 + yarn.lock | 5 + 34 files changed, 1847 insertions(+), 113 deletions(-) create mode 100644 shells/dev/app/InspectableElements/FunctionWithState.js create mode 100644 shells/dev/app/InspectableElements/InspectableElements.js create mode 100644 shells/dev/app/InspectableElements/NestedProps.js create mode 100644 shells/dev/app/InspectableElements/index.js create mode 100644 src/backend/ReactDebugHooks.js create mode 100644 src/devtools/views/HooksTree.css create mode 100644 src/devtools/views/HooksTree.js create mode 100644 src/devtools/views/Icon.css create mode 100644 src/devtools/views/Icon.js create mode 100644 src/devtools/views/InspectedElementTree.css create mode 100644 src/devtools/views/InspectedElementTree.js create mode 100644 src/hydration.js diff --git a/package.json b/package.json index 81f577f34180b..4d2d7222164d0 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react": "^16.8.0-alpha.1", "react-color": "^2.11.7", "react-dom": "^16.8.0-alpha.1", + "react-is": "^16.8.0-alpha.1", "react-portal": "^3.1.0", "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.5.1", diff --git a/shells/dev/app/App.js b/shells/dev/app/App.js index 7d009f251ec53..787a5af9fdf72 100644 --- a/shells/dev/app/App.js +++ b/shells/dev/app/App.js @@ -1,8 +1,9 @@ // @flow import React from 'react'; -import List from './ToDoList'; import ElementTypes from './ElementTypes'; +import InspectableElements from './InspectableElements'; +import List from './ToDoList'; import styles from './App.css'; export default function App() { @@ -10,6 +11,7 @@ export default function App() {
+
); } diff --git a/shells/dev/app/InspectableElements/FunctionWithState.js b/shells/dev/app/InspectableElements/FunctionWithState.js new file mode 100644 index 0000000000000..ae445fc246a5e --- /dev/null +++ b/shells/dev/app/InspectableElements/FunctionWithState.js @@ -0,0 +1,16 @@ +// @flow + +import React, { useCallback, useState } from 'react'; + +type Props = {| + initialCount: number, +|}; + +export default function FunctionWithState({ initialCount }: Props) { + const [count, setCount] = useState(initialCount); + const handleClick = useCallback(() => { + setCount(count => count + 1); + }); + + return ; +} diff --git a/shells/dev/app/InspectableElements/InspectableElements.js b/shells/dev/app/InspectableElements/InspectableElements.js new file mode 100644 index 0000000000000..1da1b93989754 --- /dev/null +++ b/shells/dev/app/InspectableElements/InspectableElements.js @@ -0,0 +1,14 @@ +// @flow + +import React, { Fragment } from 'react'; +import FunctionWithState from './FunctionWithState'; +import NestedProps from './NestedProps'; + +export default function InspectableElements() { + return ( + + + + + ); +} diff --git a/shells/dev/app/InspectableElements/NestedProps.js b/shells/dev/app/InspectableElements/NestedProps.js new file mode 100644 index 0000000000000..7b2c899df1b6e --- /dev/null +++ b/shells/dev/app/InspectableElements/NestedProps.js @@ -0,0 +1,32 @@ +// @flow + +import React from 'react'; + +export default function ObjectProps() { + return ( + + ); +} + +function ChildComponent(props: any) { + return null; +} diff --git a/shells/dev/app/InspectableElements/index.js b/shells/dev/app/InspectableElements/index.js new file mode 100644 index 0000000000000..58efb1216ec8b --- /dev/null +++ b/shells/dev/app/InspectableElements/index.js @@ -0,0 +1,5 @@ +// @flow + +import InspectableElements from './InspectableElements'; + +export default InspectableElements; diff --git a/src/backend/ReactDebugHooks.js b/src/backend/ReactDebugHooks.js new file mode 100644 index 0000000000000..1c62f740aff58 --- /dev/null +++ b/src/backend/ReactDebugHooks.js @@ -0,0 +1,573 @@ +// @flow + +// This file was forked from the React GitHub repo: +// https://github.com/facebook/react/blob/master/packages/react-debug-tools/src/ReactDebugHooks.js +// It has been modified slightly though to account for "shared" imports and different lint configs. +// I've also removed some of the Flow types that don't exist in DevTools. +// TODO Remove this fork and use the NPM version of this package once it's released. + +import ErrorStackParser from 'error-stack-parser'; + +type Fiber = any; +type Hook = any; + +// HACK: These values are copied from attachRendererFiber +// In the future, the react-debug-hooks package will be published to NPM, +// and be locked to a specific range of react versions +// For now we are just hard-coding the current/latest versions. +const ContextProvider = 10; +const ForwardRef = 11; +const FunctionComponent = 0; +const SimpleMemoComponent = 15; + +// Used to track hooks called during a render + +type HookLogEntry = { + primitive: string, + stackError: Error, + value: mixed, +}; + +let hookLog: Array = []; + +// Primitives + +type BasicStateAction = (S => S) | S; + +type Dispatch = A => void; + +let primitiveStackCache: null | Map> = null; + +function getPrimitiveStackCache(): Map> { + // This initializes a cache of all primitive hooks so that the top + // most stack frames added by calling the primitive hook can be removed. + if (primitiveStackCache === null) { + const cache = new Map(); + let readHookLog; + try { + // Use all hooks here to add them to the hook log. + Dispatcher.useContext(({ _currentValue: null }: any)); + Dispatcher.useState(null); + Dispatcher.useReducer((s, a) => s, null); + Dispatcher.useRef(null); + Dispatcher.useLayoutEffect(() => {}); + Dispatcher.useEffect(() => {}); + Dispatcher.useImperativeHandle(undefined, () => null); + Dispatcher.useCallback(() => {}); + Dispatcher.useMemo(() => null); + Dispatcher.useDebugValue(null); + } finally { + readHookLog = hookLog; + hookLog = []; + } + for (let i = 0; i < readHookLog.length; i++) { + const hook = readHookLog[i]; + cache.set(hook.primitive, ErrorStackParser.parse(hook.stackError)); + } + primitiveStackCache = cache; + } + return primitiveStackCache; +} + +let currentHook: null | Hook = null; +function nextHook(): null | Hook { + const hook = currentHook; + if (hook !== null) { + currentHook = hook.next; + } + return hook; +} + +function readContext( + context: any, + observedBits: void | number | boolean +): T { + // For now we don't expose readContext usage in the hooks debugging info. + return context._currentValue; +} + +function useContext(context: any, observedBits: void | number | boolean): T { + hookLog.push({ + primitive: 'Context', + stackError: new Error(), + value: context._currentValue, + }); + return context._currentValue; +} + +function useState( + initialState: (() => S) | S +): [S, Dispatch>] { + const hook = nextHook(); + const state: S = + hook !== null + ? hook.memoizedState + : typeof initialState === 'function' + ? (initialState: any)() + : initialState; + hookLog.push({ primitive: 'State', stackError: new Error(), value: state }); + return [state, (action: BasicStateAction) => {}]; +} + +function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null +): [S, Dispatch] { + const hook = nextHook(); + const state = hook !== null ? hook.memoizedState : initialState; + hookLog.push({ + primitive: 'Reducer', + stackError: new Error(), + value: state, + }); + return [state, (action: A) => {}]; +} + +function useRef(initialValue: T): { current: T } { + const hook = nextHook(); + const ref = hook !== null ? hook.memoizedState : { current: initialValue }; + hookLog.push({ + primitive: 'Ref', + stackError: new Error(), + value: ref.current, + }); + return ref; +} + +function useLayoutEffect( + create: () => mixed, + inputs: Array | void | null +): void { + nextHook(); + hookLog.push({ + primitive: 'LayoutEffect', + stackError: new Error(), + value: create, + }); +} + +function useEffect( + create: () => mixed, + inputs: Array | void | null +): void { + nextHook(); + hookLog.push({ primitive: 'Effect', stackError: new Error(), value: create }); +} + +function useImperativeHandle( + ref: { current: T | null } | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null +): void { + nextHook(); + // We don't actually store the instance anywhere if there is no ref callback + // and if there is a ref callback it might not store it but if it does we + // have no way of knowing where. So let's only enable introspection of the + // ref itself if it is using the object form. + let instance = undefined; + if (ref !== null && typeof ref === 'object') { + instance = ref.current; + } + hookLog.push({ + primitive: 'ImperativeHandle', + stackError: new Error(), + value: instance, + }); +} + +function useCallback(callback: T, inputs: Array | void | null): T { + const hook = nextHook(); + hookLog.push({ + primitive: 'Callback', + stackError: new Error(), + value: hook !== null ? hook.memoizedState[0] : callback, + }); + return callback; +} + +function useDebugValue(value: any, formatterFn: ?(value: any) => any) { + hookLog.push({ + primitive: 'DebugValue', + stackError: new Error(), + value: typeof formatterFn === 'function' ? formatterFn(value) : value, + }); +} + +function useMemo( + nextCreate: () => T, + inputs: Array | void | null +): T { + const hook = nextHook(); + const value = hook !== null ? hook.memoizedState[0] : nextCreate(); + hookLog.push({ primitive: 'Memo', stackError: new Error(), value }); + return value; +} + +const Dispatcher = { + readContext, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useDebugValue, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +}; + +// Inspect + +type ReactCurrentDispatcher = { + current: null | typeof Dispatcher, +}; + +type HooksNode = { + name: string, + value: mixed, + subHooks: Array, +}; +type HooksTree = Array; + +// Don't assume +// +// We can't assume that stack frames are nth steps away from anything. +// E.g. we can't assume that the root call shares all frames with the stack +// of a hook call. A simple way to demonstrate this is wrapping `new Error()` +// in a wrapper constructor like a polyfill. That'll add an extra frame. +// Similar things can happen with the call to the dispatcher. The top frame +// may not be the primitive. Likewise the primitive can have fewer stack frames +// such as when a call to useState got inlined to use dispatcher.useState. +// +// We also can't assume that the last frame of the root call is the same +// frame as the last frame of the hook call because long stack traces can be +// truncated to a stack trace limit. + +let mostLikelyAncestorIndex = 0; + +function findSharedIndex(hookStack, rootStack, rootIndex) { + const source = rootStack[rootIndex].source; + hookSearch: for (let i = 0; i < hookStack.length; i++) { + if (hookStack[i].source === source) { + // This looks like a match. Validate that the rest of both stack match up. + for ( + let a = rootIndex + 1, b = i + 1; + a < rootStack.length && b < hookStack.length; + a++, b++ + ) { + if (hookStack[b].source !== rootStack[a].source) { + // If not, give up and try a different match. + continue hookSearch; + } + } + return i; + } + } + return -1; +} + +function findCommonAncestorIndex(rootStack, hookStack) { + let rootIndex = findSharedIndex( + hookStack, + rootStack, + mostLikelyAncestorIndex + ); + if (rootIndex !== -1) { + return rootIndex; + } + // If the most likely one wasn't a hit, try any other frame to see if it is shared. + // If that takes more than 5 frames, something probably went wrong. + for (let i = 0; i < rootStack.length && i < 5; i++) { + rootIndex = findSharedIndex(hookStack, rootStack, i); + if (rootIndex !== -1) { + mostLikelyAncestorIndex = i; + return rootIndex; + } + } + return -1; +} + +function isReactWrapper(functionName, primitiveName) { + if (!functionName) { + return false; + } + const expectedPrimitiveName = 'use' + primitiveName; + if (functionName.length < expectedPrimitiveName.length) { + return false; + } + return ( + functionName.lastIndexOf(expectedPrimitiveName) === + functionName.length - expectedPrimitiveName.length + ); +} + +function findPrimitiveIndex(hookStack, hook) { + const stackCache = getPrimitiveStackCache(); + const primitiveStack = stackCache.get(hook.primitive); + if (primitiveStack === undefined) { + return -1; + } + for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) { + if (primitiveStack[i].source !== hookStack[i].source) { + // If the next two frames are functions called `useX` then we assume that they're part of the + // wrappers that the React packager or other packages adds around the dispatcher. + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.primitive) + ) { + i++; + } + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.primitive) + ) { + i++; + } + return i; + } + } + return -1; +} + +function parseTrimmedStack(rootStack, hook) { + // Get the stack trace between the primitive hook function and + // the root function call. I.e. the stack frames of custom hooks. + const hookStack = ErrorStackParser.parse(hook.stackError); + const rootIndex = findCommonAncestorIndex(rootStack, hookStack); + const primitiveIndex = findPrimitiveIndex(hookStack, hook); + if ( + rootIndex === -1 || + primitiveIndex === -1 || + rootIndex - primitiveIndex < 2 + ) { + // Something went wrong. Give up. + return null; + } + return hookStack.slice(primitiveIndex, rootIndex - 1); +} + +function parseCustomHookName(functionName: void | string): string { + if (!functionName) { + return ''; + } + let startIndex = functionName.lastIndexOf('.'); + if (startIndex === -1) { + startIndex = 0; + } + if (functionName.substr(startIndex, 3) === 'use') { + startIndex += 3; + } + return functionName.substr(startIndex); +} + +function buildTree(rootStack, readHookLog): HooksTree { + const rootChildren = []; + let prevStack = null; + let levelChildren = rootChildren; + const stackOfChildren = []; + for (let i = 0; i < readHookLog.length; i++) { + const hook = readHookLog[i]; + const stack = parseTrimmedStack(rootStack, hook); + if (stack !== null) { + // Note: The indices 0 <= n < length-1 will contain the names. + // The indices 1 <= n < length will contain the source locations. + // That's why we get the name from n - 1 and don't check the source + // of index 0. + let commonSteps = 0; + if (prevStack !== null) { + // Compare the current level's stack to the new stack. + while (commonSteps < stack.length && commonSteps < prevStack.length) { + const stackSource = stack[stack.length - commonSteps - 1].source; + const prevSource = + prevStack[prevStack.length - commonSteps - 1].source; + if (stackSource !== prevSource) { + break; + } + commonSteps++; + } + // Pop back the stack as many steps as were not common. + for (let j = prevStack.length - 1; j > commonSteps; j--) { + levelChildren = stackOfChildren.pop(); + } + } + // The remaining part of the new stack are custom hooks. Push them + // to the tree. + for (let j = stack.length - commonSteps - 1; j >= 1; j--) { + const children = []; + levelChildren.push({ + name: parseCustomHookName(stack[j - 1].functionName), + value: undefined, + subHooks: children, + }); + stackOfChildren.push(levelChildren); + levelChildren = children; + } + prevStack = stack; + } + levelChildren.push({ + name: hook.primitive, + value: hook.value, + subHooks: [], + }); + } + + // Associate custom hook values (useDebugValue() hook entries) with the correct hooks. + rollupDebugValues(rootChildren, null); + + return rootChildren; +} + +// Custom hooks support user-configurable labels (via the useDebugValue() hook). +// That hook adds the user-provided values to the hooks tree. +// This method removes those values (so they don't appear in DevTools), +// and bubbles them up to the "value" attribute of their parent custom hook. +function rollupDebugValues( + hooksTree: HooksTree, + parentHooksNode: HooksNode | null +): void { + const debugValueHooksNodes: Array = []; + + for (let i = 0; i < hooksTree.length; i++) { + const hooksNode = hooksTree[i]; + if (hooksNode.name === 'DebugValue' && hooksNode.subHooks.length === 0) { + hooksTree.splice(i, 1); + i--; + debugValueHooksNodes.push(hooksNode); + } else { + rollupDebugValues(hooksNode.subHooks, hooksNode); + } + } + + // Bubble debug value labels to their parent custom hook. + // If there is no parent hook, just ignore them. + // (We may warn about this in the future.) + if (parentHooksNode !== null) { + if (debugValueHooksNodes.length === 1) { + parentHooksNode.value = debugValueHooksNodes[0].value; + } else if (debugValueHooksNodes.length > 1) { + parentHooksNode.value = debugValueHooksNodes.map(({ value }) => value); + } + } +} + +export function inspectHooks( + renderFunction: Props => React$Node, + props: Props, + currentDispatcher: ReactCurrentDispatcher +): HooksTree { + const previousDispatcher = currentDispatcher.current; + let readHookLog; + currentDispatcher.current = Dispatcher; + let ancestorStackError; + try { + ancestorStackError = new Error(); + renderFunction(props); + } finally { + readHookLog = hookLog; + hookLog = []; + currentDispatcher.current = previousDispatcher; + } + const rootStack = ErrorStackParser.parse(ancestorStackError); + return buildTree(rootStack, readHookLog); +} + +function setupContexts(contextMap: Map, fiber: Fiber) { + let current = fiber; + while (current) { + if (current.tag === ContextProvider) { + const providerType: any = current.type; + const context: any = providerType._context; + if (!contextMap.has(context)) { + // Store the current value that we're going to restore later. + contextMap.set(context, context._currentValue); + // Set the inner most provider value on the context. + context._currentValue = current.memoizedProps.value; + } + } + current = current.return; + } +} + +function restoreContexts(contextMap: Map) { + contextMap.forEach((value, context) => (context._currentValue = value)); +} + +function inspectHooksOfForwardRef( + renderFunction: (Props, Ref) => React$Node, + props: Props, + ref: Ref, + currentDispatcher: ReactCurrentDispatcher +): HooksTree { + const previousDispatcher = currentDispatcher.current; + let readHookLog; + currentDispatcher.current = Dispatcher; + let ancestorStackError; + try { + ancestorStackError = new Error(); + renderFunction(props, ref); + } finally { + readHookLog = hookLog; + hookLog = []; + currentDispatcher.current = previousDispatcher; + } + const rootStack = ErrorStackParser.parse(ancestorStackError); + return buildTree(rootStack, readHookLog); +} + +function resolveDefaultProps(Component, baseProps) { + if (Component && Component.defaultProps) { + // Resolve default props. Taken from ReactElement + const props = Object.assign({}, baseProps); + const defaultProps = Component.defaultProps; + for (const propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } + return props; + } + return baseProps; +} + +export function inspectHooksOfFiber( + fiber: Fiber, + currentDispatcher: ReactCurrentDispatcher +) { + if ( + fiber.tag !== FunctionComponent && + fiber.tag !== SimpleMemoComponent && + fiber.tag !== ForwardRef + ) { + throw new Error( + 'Unknown Fiber. Needs to be a function component to inspect hooks.' + ); + } + // Warm up the cache so that it doesn't consume the currentHook. + getPrimitiveStackCache(); + const type = fiber.type; + let props = fiber.memoizedProps; + if (type !== fiber.elementType) { + props = resolveDefaultProps(type, props); + } + // Set up the current hook so that we can step through and read the + // current state from them. + currentHook = (fiber.memoizedState: Hook); + const contextMap = new Map(); + try { + setupContexts(contextMap, fiber); + if (fiber.tag === ForwardRef) { + return inspectHooksOfForwardRef( + type.render, + props, + fiber.ref, + currentDispatcher + ); + } + return inspectHooks(type, props, currentDispatcher); + } finally { + currentHook = null; + restoreContexts(contextMap); + } +} diff --git a/src/backend/agent.js b/src/backend/agent.js index d33a59432175f..377e3d86fc11e 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -24,12 +24,23 @@ export default class Agent extends EventEmitter { addBridge(bridge: Bridge) { this._bridge = bridge; - bridge.on('shutdown', () => this.emit('shutdown')); + bridge.addListener('shutdown', () => this.emit('shutdown')); + bridge.addListener('inspectElement', this.inspectElement); // TODO Listen to bridge for things like selection. // bridge.on('...'), this...); } + inspectElement = ({ id, rendererID }: { id: number, rendererID: number }) => { + const renderer = this._rendererInterfaces[rendererID]; + + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + this._bridge.send('inspectedElement', renderer.inspectElement(id)); + } + }; + setRendererInterface( rendererID: RendererID, rendererInterface: RendererInterface @@ -37,16 +48,6 @@ export default class Agent extends EventEmitter { this._rendererInterfaces[rendererID] = rendererInterface; } - onHookDisplayNames = (displayNames: Map) => { - debug('onHookDisplayNames', displayNames); - this._bridge.send('displayNames', displayNames); - }; - - onHookKeys = (keys: Map) => { - debug('onHookKeys', keys); - this._bridge.send('keys', keys); - }; - onHookOperations = (operations: Uint32Array) => { debug('onHookOperations', operations); this._bridge.send('operations', operations, [operations.buffer]); diff --git a/src/backend/index.js b/src/backend/index.js index 029bd655c4075..85382f380564a 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -1,16 +1,27 @@ // @flow -import type { Hook } from './types'; +import type { Hook, ReactRenderer, RendererInterface } from './types'; import Agent from './agent'; import { attach } from './renderer'; export function initBackend(hook: Hook, agent: Agent): void { const subs = [ - hook.sub('renderer-attached', ({ id, renderer, rendererInterface }) => { - agent.setRendererInterface(id, rendererInterface); - rendererInterface.walkTree(); - }), + hook.sub( + 'renderer-attached', + ({ + id, + renderer, + rendererInterface, + }: { + id: number, + renderer: ReactRenderer, + rendererInterface: RendererInterface, + }) => { + agent.setRendererInterface(id, rendererInterface); + rendererInterface.walkTree(); + } + ), hook.sub('operations', agent.onHookOperations), hook.sub('rootCommitted', agent.onHookRootCommitted), @@ -18,9 +29,9 @@ export function initBackend(hook: Hook, agent: Agent): void { // TODO Add additional subscriptions required for profiling mode ]; - const attachRenderer = (id, renderer) => { + const attachRenderer = (id: number, renderer: ReactRenderer) => { const rendererInterface = attach(hook, id, renderer); - hook.rendererInterfaces[id] = rendererInterface; + hook.rendererInterfaces.set(id, rendererInterface); hook.emit('renderer-attached', { id, renderer, @@ -29,23 +40,25 @@ export function initBackend(hook: Hook, agent: Agent): void { }; // Connect renderers that have already injected themselves. - for (let id in hook.renderers) { - const renderer = hook.renderers[id]; + hook.renderers.forEach((renderer, id) => { attachRenderer(id, renderer); - } + }); // Connect any new renderers that injected themselves. - hook.on('renderer', ({ id, renderer }) => { - attachRenderer(id, renderer); - }); + hook.on( + 'renderer', + ({ id, renderer }: { id: number, renderer: ReactRenderer }) => { + attachRenderer(id, renderer); + } + ); hook.emit('react-devtools', agent); hook.reactDevtoolsAgent = agent; agent.addListener('shutdown', () => { subs.forEach(fn => fn()); - for (let id in hook.rendererInterfaces) { - hook.rendererInterfaces[id].cleanup(); - } + hook.rendererInterfaces.forEach(rendererInterface => { + rendererInterface.cleanup(); + }); hook.reactDevtoolsAgent = null; }); } diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 815225a2bc5cf..dbb7b3ea8b3a0 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -12,13 +12,15 @@ import { ElementTypeSuspense, } from 'src/devtools/types'; import { utfEncodeString } from '../utils'; -import { getDisplayName } from './utils'; +import { cleanForBridge, cleanPropsForBridge, getDisplayName } from './utils'; import { __DEBUG__, TREE_OPERATION_ADD, TREE_OPERATION_REMOVE, TREE_OPERATION_RESET_CHILDREN, } from '../constants'; +import { getUID } from '../utils'; +import { inspectHooksOfFiber } from './ReactDebugHooks'; import type { Fiber, @@ -27,6 +29,7 @@ import type { FiberData, RendererInterface, } from './types'; +import type { InspectedElement } from 'src/devtools/types'; function getInternalReactConstants(version) { const ReactSymbols = { @@ -51,7 +54,9 @@ function getInternalReactConstants(version) { }; const ReactTypeOfSideEffect = { - PerformedWork: 1, + NoEffect: 0b00, + PerformedWork: 0b01, + Placement: 0b10, }; let ReactTypeOfWork; @@ -146,7 +151,7 @@ function getInternalReactConstants(version) { export function attach( hook: Hook, - rendererID: string, + rendererID: number, renderer: ReactRenderer ): RendererInterface { const { @@ -154,7 +159,7 @@ export function attach( ReactSymbols, ReactTypeOfSideEffect, } = getInternalReactConstants(renderer.version); - const { PerformedWork } = ReactTypeOfSideEffect; + const { NoEffect, PerformedWork, Placement } = ReactTypeOfSideEffect; const { FunctionComponent, ClassComponent, @@ -212,8 +217,6 @@ export function attach( } }; - const primaryFibers: WeakSet = new WeakSet(); - // Keep this function in sync with getDataForFiber() function shouldFilterFiber(fiber: Fiber): boolean { const { type, tag } = fiber; @@ -443,12 +446,15 @@ export function attach( return fiber; } - let uidCounter: number = 0; - const fiberToIDMap: WeakMap = new WeakMap(); + const fiberToIDMap: Map = new Map(); + const idToFiberMap: Map = new Map(); + const primaryFibers: Set = new Set(); function getFiberID(primaryFiber: Fiber): number { if (!fiberToIDMap.has(primaryFiber)) { - fiberToIDMap.set(primaryFiber, ++uidCounter); + const id = getUID(); + fiberToIDMap.set(primaryFiber, id); + idToFiberMap.set(id, primaryFiber); } return ((fiberToIDMap.get(primaryFiber): any): number); } @@ -509,6 +515,13 @@ export function attach( } function flushPendingEvents(root: Object): void { + // Identify which renderer this update is coming from. + // This enables roots to be mapped to renderers, + // Which in turn enables fiber props, states, and hooks to be inspected. + const idArray = new Uint32Array(1); + idArray[0] = rendererID; + addOperation(idArray, true); + // Let the frontend know about tree operations. hook.emit('operations', pendingOperations); pendingOperations = new Uint32Array(0); @@ -575,8 +588,8 @@ export function attach( function enqueueUnmount(fiber) { const isRoot = fiber.tag === HostRoot; const primaryFiber = getPrimaryFiber(fiber); + const id = getFiberID(primaryFiber); if (isRoot) { - const id = getFiberID(getPrimaryFiber(fiber)); const operation = new Uint32Array(2); operation[0] = TREE_OPERATION_REMOVE; operation[1] = id; @@ -585,15 +598,15 @@ export function attach( // Non-root fibers are deleted during the commit phase. // They are deleted in the child-first order. However // DevTools currently expects deletions to be parent-first. - // This is why we unshift deletions rather than push them. - const id = getFiberID(getPrimaryFiber(fiber)); + // This is why we unshift deletions rather tha const operation = new Uint32Array(2); operation[0] = TREE_OPERATION_REMOVE; operation[1] = id; addOperation(operation, true); } - primaryFibers.delete(primaryFiber); + idToFiberMap.delete(id); fiberToIDMap.delete(primaryFiber); + primaryFibers.delete(primaryFiber); } function mountFiber(fiber: Fiber, parentFiber: Fiber | null) { @@ -820,11 +833,255 @@ export function attach( return null; } + const MOUNTING = 1; + const MOUNTED = 2; + const UNMOUNTED = 3; + + // This function is copied from React and should be kept in sync: + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + function isFiberMountedImpl(fiber: Fiber): number { + let node = fiber; + if (!fiber.alternate) { + // If there is no alternate, this might be a new tree that isn't inserted + // yet. If it is, then it will have a pending insertion effect on it. + if ((node.effectTag & Placement) !== NoEffect) { + return MOUNTING; + } + while (node.return) { + node = node.return; + if ((node.effectTag & Placement) !== NoEffect) { + return MOUNTING; + } + } + } else { + while (node.return) { + node = node.return; + } + } + if (node.tag === HostRoot) { + // TODO: Check if this was a nested HostRoot when used with + // renderContainerIntoSubtree. + return MOUNTED; + } + // If we didn't hit the root, that means that we're in an disconnected tree + // that has been unmounted. + return UNMOUNTED; + } + + // This function is copied from React and should be kept in sync: + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + // It would be nice if we updated React to inject this function directly (vs just indirectly via findDOMNode). + function findCurrentFiberUsingSlowPath(fiber: Fiber): Fiber | null { + let alternate = fiber.alternate; + if (!alternate) { + // If there is no alternate, then we only need to check if it is mounted. + const state = isFiberMountedImpl(fiber); + if (state === UNMOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + if (state === MOUNTING) { + return null; + } + return fiber; + } + // If we have two possible branches, we'll walk backwards up to the root + // to see what path the root points to. On the way we may hit one of the + // special cases and we'll deal with them. + let a = fiber; + let b = alternate; + while (true) { + let parentA = a.return; + let parentB = parentA ? parentA.alternate : null; + if (!parentA || !parentB) { + // We're at the root. + break; + } + + // If both copies of the parent fiber point to the same child, we can + // assume that the child is current. This happens when we bailout on low + // priority: the bailed out fiber's child reuses the current child. + if (parentA.child === parentB.child) { + let child = parentA.child; + while (child) { + if (child === a) { + // We've determined that A is the current branch. + if (isFiberMountedImpl(parentA) !== MOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + return fiber; + } + if (child === b) { + // We've determined that B is the current branch. + if (isFiberMountedImpl(parentA) !== MOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + return alternate; + } + child = child.sibling; + } + // We should never have an alternate for any mounting node. So the only + // way this could possibly happen is if this was unmounted, if at all. + throw Error('Unable to find node on an unmounted component.'); + } + + if (a.return !== b.return) { + // The return pointer of A and the return pointer of B point to different + // fibers. We assume that return pointers never criss-cross, so A must + // belong to the child set of A.return, and B must belong to the child + // set of B.return. + a = parentA; + b = parentB; + } else { + // The return pointers point to the same fiber. We'll have to use the + // default, slow path: scan the child sets of each parent alternate to see + // which child belongs to which set. + // + // Search parent A's child set + let didFindChild = false; + let child = parentA.child; + while (child) { + if (child === a) { + didFindChild = true; + a = parentA; + b = parentB; + break; + } + if (child === b) { + didFindChild = true; + b = parentA; + a = parentB; + break; + } + child = child.sibling; + } + if (!didFindChild) { + // Search parent B's child set + child = parentB.child; + while (child) { + if (child === a) { + didFindChild = true; + a = parentB; + b = parentA; + break; + } + if (child === b) { + didFindChild = true; + b = parentB; + a = parentA; + break; + } + child = child.sibling; + } + if (!didFindChild) { + throw Error( + 'Child was not found in either parent set. This indicates a bug ' + + 'in React related to the return pointer. Please file an issue.' + ); + } + } + } + + if (a.alternate !== b) { + throw Error( + "Return fibers should always be each others' alternates. " + + 'This error is likely caused by a bug in React. Please file an issue.' + ); + } + } + // If the root is not a host container, we're in a disconnected tree. I.e. + // unmounted. + if (a.tag !== HostRoot) { + throw Error('Unable to find node on an unmounted component.'); + } + if (a.stateNode.current === a) { + // We've determined that A is the current branch. + return fiber; + } + // Otherwise B has to be current branch. + return alternate; + } + + function inspectElement(id: number): InspectedElement | null { + let fiber = idToFiberMap.get(id); + + if (fiber == null) { + console.warn(`Could not find Fiber with id "${id}"`); + return null; + } + + // Find the currently mounted version of this fiber (so we don't show the wrong props and state). + fiber = findCurrentFiberUsingSlowPath(fiber); + + const { + _debugOwner, + _debugSource, + stateNode, + memoizedProps, + memoizedState, + tag, + } = ((fiber: any): Fiber); + + const usesHooks = + (tag === FunctionComponent || + tag === SimpleMemoComponent || + tag === ForwardRef) && + !!memoizedState; + + const hasContext = + tag === ClassComponent || + tag === FunctionComponent || + tag === IncompleteClassComponent || + tag === IndeterminateComponent; + + let owners = null; + if (_debugOwner) { + owners = []; + let owner = _debugOwner; + while (owner !== null) { + owners.push({ + displayName: getDataForFiber(owner).displayName || 'Unknown', + id: getFiberID(getPrimaryFiber(owner)), + }); + owner = owner._debugOwner; + } + } + + return { + id, + + // Does the current renderer support editable props/state/hooks? + canEditValues: false, // TODO + + // Inspectable properties. + // TODO Sanitize props, state, and context + context: + (hasContext && + stateNode && + Object.keys(stateNode.context).length > 0 && + cleanForBridge(stateNode.context)) || + null, + hooks: usesHooks + ? cleanForBridge( + inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any)) + ) + : null, + props: cleanPropsForBridge(memoizedProps), + state: usesHooks ? null : cleanForBridge(memoizedState), + + // List of owners + owners, + + // Location of component in source coude. + source: _debugSource, + }; + } + return { getNativeFromReactElement, getReactElementFromNative, handleCommitFiberRoot, handleCommitFiberUnmount, + inspectElement, cleanup, walkTree, renderer, diff --git a/src/backend/types.js b/src/backend/types.js index ab05c3c3073e3..eaf7358539009 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -1,6 +1,6 @@ // @flow -import type { ElementType } from 'src/devtools/types'; +import type { ElementType, InspectedElement } from 'src/devtools/types'; type BundleType = | 0 // PROD @@ -24,7 +24,9 @@ export type Interaction = {| |}; export type NativeType = {}; -export type RendererID = string; +export type RendererID = number; + +type Dispatcher = any; export type ReactRenderer = { findHostInstanceByFiber: (fiber: Object) => ?NativeType, @@ -36,6 +38,9 @@ export type ReactRenderer = { path: Array, value: any ) => void, + + // Only injected by React v16.8+ in order to support hooks inspection. + currentDispatcherRef?: {| current: null | Dispatcher |}, }; export type RendererInterface = { @@ -43,6 +48,7 @@ export type RendererInterface = { getNativeFromReactElement?: ?(component: Fiber) => ?NativeType, getReactElementFromNative?: ?(component: NativeType) => ?Fiber, handleCommitFiberRoot: (fiber: Object) => void, + inspectElement: (id: number) => InspectedElement | null, renderer: ReactRenderer | null, walkTree: () => void, }; @@ -51,11 +57,11 @@ export type Handler = (data: any) => void; export type Hook = { listeners: { [key: string]: Array }, - rendererInterfaces: { [key: string]: RendererInterface }, - renderers: { [key: string]: ReactRenderer }, + rendererInterfaces: Map, + renderers: Map, emit: (evt: string, data: any) => void, - getFiberRoots: (rendererID: string) => Set, + getFiberRoots: (rendererID: RendererID) => Set, inject: (renderer: ReactRenderer) => string | null, on: (evt: string, handler: Handler) => void, off: (evt: string, handler: Handler) => void, @@ -67,3 +73,16 @@ export type Hook = { onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void, onCommitFiberRoot: (rendererID: RendererID, fiber: Object) => void, }; + +export type HooksNode = { + name: string, + value: mixed, + subHooks: Array, +}; +export type HooksTree = Array; + +export type InspectedHooks = {| + elementID: string, + id: string, + hooksTree: HooksTree, +|}; diff --git a/src/backend/utils.js b/src/backend/utils.js index 00bd50760026b..0a4837d0f97fc 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -1,5 +1,10 @@ // @flow +import { isElement } from 'react-is'; +import { dehydrate } from '../hydration'; + +import type { DehydratedData } from 'src/devtools/types'; + const FB_MODULE_RE = /^(.*) \[from (.*)\]$/; const cachedDisplayNames: WeakMap = new WeakMap(); @@ -45,11 +50,38 @@ export function getDisplayName( return displayName; } -export function guid(): string { - return ( - 'g' + - Math.random() - .toString(16) - .substr(2) - ); +export function cleanForBridge(data: Object | null): DehydratedData | null { + if (data !== null) { + const cleaned = []; + + return { + data: dehydrate(data, cleaned), + cleaned, + }; + } else { + return null; + } +} + +export function cleanPropsForBridge(props: Object): DehydratedData | null { + if (props !== null) { + const dehydratedData = cleanForBridge(props); + if (dehydratedData !== null) { + // For now, let's just filter children that are React elements. + const { children } = props; + if (children != null) { + if (isElement(children)) { + delete dehydratedData.data.children; + } else if (Array.isArray(children)) { + dehydratedData.data.children = children.filter( + child => !isElement(child) + ); + } + } + + return dehydratedData; + } + } + + return null; } diff --git a/src/devtools/store.js b/src/devtools/store.js index b698afd7758bc..8982abe9fc90c 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -40,6 +40,9 @@ export default class Store extends EventEmitter { // Passive effects will check it for changes between render and mount. _roots: $ReadOnlyArray = []; + // Renderer ID is needed to support inspection fiber props, state, and hooks. + _rootIDToRendererID: Map = new Map(); + constructor(bridge: Bridge) { super(); @@ -117,6 +120,70 @@ export default class Store extends EventEmitter { return element; } + getIndexOfElementID(id: number): number | null { + const element = this.getElementByID(id); + + if (element === null) { + return null; + } + + // Walk up the tree to the root. + // Increment the index by one for each node we encounter, + // and by the weight of all nodes to the left of the current one. + // This should be a relatively fast way of determining the index of a node within the tree. + let previousID = id; + let currentID = element.parentID; + let index = 0; + while (true) { + const current = ((this._idToElement.get(currentID): any): Element); + if (current.parentID === 0) { + // We found the root; stop crawling. + break; + } + + index++; + + const { children } = current; + for (let i = 0; i < children.length; i++) { + const childID = children[i]; + if (childID === previousID) { + break; + } + const child = ((this._idToElement.get(childID): any): Element); + index += child.weight; + } + + previousID = current.id; + currentID = current.parentID; + } + + // At this point, the current ID is a root (from the previous loop). + // We also need to offset the index by previous root weights. + for (let i = 0; i < this._roots.length; i++) { + const rootID = this._roots[i]; + if (rootID === currentID) { + break; + } + const root = ((this._idToElement.get(rootID): any): Element); + index += root.weight; + } + + return index; + } + + getRendererIDForElement(id: number): number | null { + let current = this._idToElement.get(id); + while (current != null) { + if (current.parentID === 0) { + const rendererID = this._rootIDToRendererID.get(current.id); + return rendererID == null ? null : ((rendererID: any): number); + } else { + current = this._idToElement.get(current.parentID); + } + } + return null; + } + onBridgeOperations = (operations: Uint32Array) => { if (!(operations instanceof Uint32Array)) { // $FlowFixMe TODO HACK Temporary workaround for the fact that Chrome is not transferring the typed array. @@ -127,7 +194,9 @@ export default class Store extends EventEmitter { let haveRootsChanged = false; - let i = 0; + const rendererID = operations[0]; + + let i = 1; while (i < operations.length) { let id: number = ((null: any): number); let element: Element = ((null: any): Element); @@ -150,6 +219,7 @@ export default class Store extends EventEmitter { debug('Add', `new root fiber ${id}`); this._roots = this._roots.concat(id); + this._rootIDToRendererID.set(id, rendererID); this._idToElement.set(id, { children: [], @@ -227,6 +297,7 @@ export default class Store extends EventEmitter { parentElement = ((this._idToElement.get(parentID): any): Element); if (parentElement == null) { this._roots = this._roots.filter(rootID => rootID !== id); + this._rootIDToRendererID.delete(id); } else { parentElement.children = parentElement.children.filter( childID => childID !== id diff --git a/src/devtools/types.js b/src/devtools/types.js index 904a415ad1903..58e421eab74b3 100644 --- a/src/devtools/types.js +++ b/src/devtools/types.js @@ -38,6 +38,11 @@ export type Element = {| weight: number, |}; +export type Owner = {| + displayName: string, + id: number, +|}; + export type InspectedElement = {| id: number, @@ -51,10 +56,10 @@ export type InspectedElement = {| state: Object | null, // List of owners - owners: Array, + owners: Array | null, // Location of component in source coude. - source: Object, + source: Object | null, |}; // TODO: Add profiling type @@ -66,3 +71,8 @@ export type TreeMetadataType = {| size: number, store: Store, |}; + +export type DehydratedData = {| + cleaned: Array>, + data: Object, +|}; diff --git a/src/devtools/views/Element.css b/src/devtools/views/Element.css index 24f54d909bfb8..9306a2750fa80 100644 --- a/src/devtools/views/Element.css +++ b/src/devtools/views/Element.css @@ -6,6 +6,8 @@ position: relative; white-space: nowrap; line-height: 20px; + display: flex; + align-items: center; } .Element:hover { background-color: var(--color-state03); @@ -16,6 +18,7 @@ /* Invert colors */ --color-component: var(--color-base00); + --color-arrow: var(--color-base00); --color-tree-tag: rgba(255, 255, 255, 0.8); --color-tree-attr-name: #b3e5fc; --color-tree-attr-value: #fff; @@ -27,10 +30,7 @@ margin-left: -1rem; width: 1rem; height: 1rem; - background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23777d88' d='M8 5v14l11-7z'/%3E%3Cpath fill='none' d='M0 24V0h24v24H0z'/%3E%3C/svg%3E"); - background-size: 12px 12px; - background-repeat: no-repeat; - background-position: center; + color: var(--color-arrow); } .ArrowOpen { transform: rotate(90deg); diff --git a/src/devtools/views/Element.js b/src/devtools/views/Element.js index b625827fdfb58..053340a466723 100644 --- a/src/devtools/views/Element.js +++ b/src/devtools/views/Element.js @@ -3,6 +3,7 @@ import React, { Fragment, useCallback, useContext } from 'react'; import { TreeContext } from './context'; import { SelectedElementContext } from './SelectedElementContext'; +import Icon from './Icon'; import styles from './Element.css'; @@ -23,6 +24,8 @@ export default function Element({ index, style }: Props) { return null; } + // TODO Add click and key handlers for toggling element open/close state. + const { children, depth, displayName, id, key } = element; const selectedElement = useContext(SelectedElementContext); @@ -33,8 +36,6 @@ export default function Element({ index, style }: Props) { [id] ); - // TODO: Add click and key handlers for toggling element open/close state. - return (
- {children.length > 0 && } + {children.length > 0 && ( + + + + )} {displayName} diff --git a/src/devtools/views/Elements.css b/src/devtools/views/Elements.css index bc9e34b53f505..dea13215c21d2 100644 --- a/src/devtools/views/Elements.css +++ b/src/devtools/views/Elements.css @@ -5,7 +5,6 @@ flex-direction: row; font-family: var(--font-family-sans); - -webkit-font-smoothing: antialiased; } .TreeWrapper { diff --git a/src/devtools/views/Elements.js b/src/devtools/views/Elements.js index 1d9e87c652585..a201174e1b926 100644 --- a/src/devtools/views/Elements.js +++ b/src/devtools/views/Elements.js @@ -1,9 +1,9 @@ // @flow -import React, { useLayoutEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import Store from '../store'; import Tree from './Tree'; -import { StoreContext, TreeContext } from './context'; +import { BridgeContext, StoreContext, TreeContext } from './context'; import SelectedElement from './SelectedElement'; import { SelectedElementController } from './SelectedElementContext'; import styles from './Elements.css'; @@ -26,7 +26,7 @@ export default function Elements({ bridge, browserName, themeName }: Props) { store, }); - useLayoutEffect(() => { + useEffect(() => { const handler = () => { setTreeContext({ size: store.numElements, @@ -34,6 +34,12 @@ export default function Elements({ bridge, browserName, themeName }: Props) { }); }; + // Check for changes that happened between async render and passive effect. + // (We had not yet subscribed to the store so we would have missed these.) + if (treeContext.size !== store.numElements) { + handler(); + } + store.addListener('rootCommitted', handler); return () => store.removeListener('rootCommitted', handler); @@ -41,19 +47,21 @@ export default function Elements({ bridge, browserName, themeName }: Props) { // TODO Flex wrappers below should be user resizable. return ( - - - -
-
- -
-
- + + + + +
+
+ +
+
+ +
-
- - - + + + + ); } diff --git a/src/devtools/views/HooksTree.css b/src/devtools/views/HooksTree.css new file mode 100644 index 0000000000000..681b63e000089 --- /dev/null +++ b/src/devtools/views/HooksTree.css @@ -0,0 +1,10 @@ +.HooksTree { + padding: 0.25rem; + border-bottom: 1px solid var(--color-base02); +} + +.ComingSoon { + padding-left: 1rem; + color: var(--color-base03); + font-style: italic; +} diff --git a/src/devtools/views/HooksTree.js b/src/devtools/views/HooksTree.js new file mode 100644 index 0000000000000..11f0414312d88 --- /dev/null +++ b/src/devtools/views/HooksTree.js @@ -0,0 +1,24 @@ +// @flow + +import React from 'react'; +import styles from './HooksTree.css'; + +import type { InspectedHooks } from 'src/backend/types'; + +type Props = {| + inspectedHooks: InspectedHooks | null, +|}; + +export default function HooksTree({ inspectedHooks }: Props) { + if (inspectedHooks === null) { + return null; + } + + // TODO + return ( +
+ hooks +
Coming soon...
+
+ ); +} diff --git a/src/devtools/views/Icon.css b/src/devtools/views/Icon.css new file mode 100644 index 0000000000000..2141ede58bd79 --- /dev/null +++ b/src/devtools/views/Icon.css @@ -0,0 +1,5 @@ +.Icon { + width: 1rem; + height: 1rem; + fill: currentColor; +} diff --git a/src/devtools/views/Icon.js b/src/devtools/views/Icon.js new file mode 100644 index 0000000000000..c58aa0afde080 --- /dev/null +++ b/src/devtools/views/Icon.js @@ -0,0 +1,35 @@ +// @flow + +import React from 'react'; +import styles from './Icon.css'; + +type Props = {| + type: 'arrow', +|}; + +export default function Icon({ type }: Props) { + let pathData = null; + switch (type) { + case 'arrow': + pathData = PATH_ARROW; + break; + default: + console.warn(`Unsupported type "${type}" specified for Icon`); + break; + } + + return ( + + + + + ); +} + +const PATH_ARROW = 'M8 5v14l11-7z'; diff --git a/src/devtools/views/InspectedElementTree.css b/src/devtools/views/InspectedElementTree.css new file mode 100644 index 0000000000000..b2ecc50e590ba --- /dev/null +++ b/src/devtools/views/InspectedElementTree.css @@ -0,0 +1,20 @@ +.InspectedElementTree { + padding: 0.25rem; + border-bottom: 1px solid var(--color-base02); +} + +.Item { +} + +.Name { + color: var(--color-tree-attr-name); +} + +.Value { + color: var(--color-tree-attr-value); +} + +.None { + color: var(--color-base03); + font-style: italic; +} diff --git a/src/devtools/views/InspectedElementTree.js b/src/devtools/views/InspectedElementTree.js new file mode 100644 index 0000000000000..365a39518d20e --- /dev/null +++ b/src/devtools/views/InspectedElementTree.js @@ -0,0 +1,126 @@ +// @flow + +import React from 'react'; +import { meta } from '../../hydration'; +import styles from './InspectedElementTree.css'; + +type Props = {| + label: string, + data: Object | null, +|}; + +export default function InspectedElementTree({ data, label }: Props) { + if (data === null || Object.keys(data).length === 0) { + return null; + } else { + // TODO Add click and key handlers for toggling element open/close state. + // TODO Support editable props + + return ( +
+
{label}
+ {Object.keys(data).map(name => ( + + ))} +
+ ); + } +} + +type KeyValueProps = {| + depth: number, + name: string, + value: any, +|}; + +function KeyValue({ depth, name, value }: KeyValueProps) { + const dataType = typeof value; + const isSimpleType = + dataType === 'number' || + dataType === 'string' || + dataType === 'boolean' || + value == null; + + let children = null; + if (isSimpleType) { + children = ( +
+ {name}:{' '} + + {dataType === 'string' ? `"${value}"` : value} + +
+ ); + } else if (value.hasOwnProperty(meta.type)) { + children = ( +
+ {name}:{' '} + {getMetaValueLabel(value)} +
+ ); + } else { + if (Array.isArray(value)) { + children = value.map((innerValue, index) => ( + + )); + children.unshift( +
+ {name}: Array +
+ ); + } else { + children = Object.entries(value).map(([name, value]) => ( + + )); + children.unshift( +
+ {name}: Object +
+ ); + } + } + + return children; +} + +function getMetaValueLabel(data: Object): string | null { + switch (data[meta.type]) { + case 'function': + return `${data[meta.name] || 'fn'}()`; + case 'object': + return 'Object'; + case 'date': + case 'symbol': + return data[meta.name]; + case 'iterator': + return `${data[meta.name]}(…)`; + case 'array_buffer': + case 'data_view': + case 'array': + case 'typed_array': + return `${data[meta.name]}[${data[meta.meta].length}]`; + default: + return null; + } +} diff --git a/src/devtools/views/SelectedElement.css b/src/devtools/views/SelectedElement.css index 140eb9e032f32..40d6bb6a22a0d 100644 --- a/src/devtools/views/SelectedElement.css +++ b/src/devtools/views/SelectedElement.css @@ -36,19 +36,47 @@ text-overflow: ellipsis; } -.Component { +.Owners { + padding: 0.25rem; +} + +.Component, +.Owner { overflow: hidden; text-overflow: ellipsis; color: var(--color-component); - flex: 1 1 auto; } -.Component:before { +.Component:before, +.Owner:before { white-space: nowrap; content: '<'; color: var(--color-tree-tag); } -.Component:after { +.Component:after, +.Owner:after { white-space: nowrap; content: '>'; color: var(--color-tree-tag); } + +.Component { + flex: 1 1 auto; +} + +.InspectedElement { + overflow: auto; + font-family: var(--font-family-monospace); + font-size: var(--font-size-tree-compact); + line-height: var(--line-height-compact); +} + +.Owner { + padding-left: 1rem; + cursor: pointer; +} + +.Loading { + padding: 0.25rem; + color: var(--color-base03); + font-style: italic; +} diff --git a/src/devtools/views/SelectedElement.js b/src/devtools/views/SelectedElement.js index 37f5c3c606c49..931616bafb3e5 100644 --- a/src/devtools/views/SelectedElement.js +++ b/src/devtools/views/SelectedElement.js @@ -1,19 +1,29 @@ // @flow -import React, { useContext } from 'react'; +import React, { useContext, useLayoutEffect, useRef, useState } from 'react'; import { SelectedElementContext } from './SelectedElementContext'; -import { StoreContext } from './context'; +import { BridgeContext, StoreContext } from './context'; import ButtonIcon from './ButtonIcon'; +import HooksTree from './HooksTree'; +import InspectedElementTree from './InspectedElementTree'; +import { hydrate } from 'src/hydration'; import styles from './SelectedElement.css'; +import type { InspectedElement } from '../types'; +import type { DehydratedData } from 'src/devtools/types'; +import type { SelectedElementContextValue } from './SelectedElementContext'; + export type Props = {||}; -export default function SelectedElement(props: Props) { - const { id } = useContext(SelectedElementContext); +export default function SelectedElement(_: Props) { + const selectedElement = useContext(SelectedElementContext); + const { id } = selectedElement; const store = useContext(StoreContext); const element = id !== null ? store.getElementByID(id) : null; - // TODO Show/hide source button based on whether debug "source" is available. + const inspectedElement = useInspectedElement(id); + + // TODO Make "view DOM" and "view source" buttons work if (element === null) { return ( @@ -23,6 +33,8 @@ export default function SelectedElement(props: Props) { ); } + const source = inspectedElement ? inspectedElement.source : null; + return (
@@ -36,15 +48,135 @@ export default function SelectedElement(props: Props) { className={styles.IconButton} title="Highlight this element in the page" > - DOM - - + {source !== null && ( + + )}
+ + {inspectedElement === null && ( +
Loading...
+ )} + + {inspectedElement !== null && ( + + )} +
+ ); +} + +type InspectedElementViewProps = {| + inspectedElement: InspectedElement, + selectedElement: SelectedElementContextValue, +|}; + +function InspectedElementView({ + inspectedElement, + selectedElement, +}: InspectedElementViewProps) { + let { context, hooks, owners, props, state } = inspectedElement; + + return ( +
+ + + + + + {owners !== null && owners.length > 0 && ( +
+
owners
+ {owners.map(owner => ( +
{ + selectedElement.id = owner.id; + }} + title={owner.displayName} + > + {owner.displayName} +
+ ))} +
+ )}
); } + +function hydrateHelper(dehydratedData: DehydratedData | null): Object | null { + if (dehydratedData !== null) { + return hydrate(dehydratedData.data, dehydratedData.cleaned); + } else { + return null; + } +} + +function useInspectedElement(id: number | null): InspectedElement | null { + const idRef = useRef(id); + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const rendererID = id === null ? null : store.getRendererIDForElement(id); + + const [inspectedElement, setInspectedElement] = useState(null); + + // Track the most recently-requested element. + // We'll ignore any backend updates about previous elements. + idRef.current = id; + + useLayoutEffect(() => { + if (id === null) { + return () => {}; + } + + // Hide previous/stale insepected element to avoid temporarily showing the wrong values. + setInspectedElement(null); + + let timeoutID = null; + + const sendBridgeRequest = () => { + bridge.send('inspectElement', { id, rendererID }); + }; + + const onInspectedElement = (inspectedElement: InspectedElement) => { + if (idRef.current !== inspectedElement.id) { + // Ignore bridge updates about previously selected elements. + return; + } + + inspectedElement.context = hydrateHelper(inspectedElement.context); + inspectedElement.hooks = hydrateHelper(inspectedElement.hooks); + inspectedElement.props = hydrateHelper(inspectedElement.props); + inspectedElement.state = hydrateHelper(inspectedElement.state); + + setInspectedElement(inspectedElement); + + // Ask for an update in a second... + timeoutID = setTimeout(sendBridgeRequest, 1000); + }; + + bridge.addListener('inspectedElement', onInspectedElement); + + sendBridgeRequest(); + + return () => { + if (timeoutID !== null) { + clearTimeout(timeoutID); + } + + bridge.removeListener('inspectedElement', onInspectedElement); + }; + }, [id]); + + return inspectedElement; +} diff --git a/src/devtools/views/SelectedElementContext.js b/src/devtools/views/SelectedElementContext.js index 7d7afce7a4a77..a930cd5dd94e6 100644 --- a/src/devtools/views/SelectedElementContext.js +++ b/src/devtools/views/SelectedElementContext.js @@ -1,9 +1,11 @@ // @flow -import React, { createContext, useMemo, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; +import { StoreContext } from './context'; -type SelectedElementContextValue = {| +export type SelectedElementContextValue = {| id: number | null, + index: number | null, |}; const SelectedElementContext = createContext( @@ -18,7 +20,11 @@ type Props = {| // TODO Remove this wrapper element once global Context.write API exists. function SelectedElementController({ children }: Props) { + const store = useContext(StoreContext); + const [id, setID] = useState(null); + const [index, setIndex] = useState(null); + const value = useMemo( () => ({ get id() { @@ -26,6 +32,15 @@ function SelectedElementController({ children }: Props) { }, set id(id: number | null) { setID(id); + setIndex(id !== null ? store.getIndexOfElementID(id) : null); + }, + get index() { + return index; + }, + set index(index: number | null) { + const element = index !== null ? store.getElementAtIndex(index) : null; + setID(element !== null ? element.id : null); + setIndex(index); }, }), [id] diff --git a/src/devtools/views/Tree.js b/src/devtools/views/Tree.js index 92bf94c04c814..aac2f89aff26a 100644 --- a/src/devtools/views/Tree.js +++ b/src/devtools/views/Tree.js @@ -1,8 +1,9 @@ // @flow -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; +import { SelectedElementContext } from './SelectedElementContext'; import Element from './Element'; import ButtonIcon from './ButtonIcon'; import { TreeContext } from './context'; @@ -12,9 +13,49 @@ import styles from './Tree.css'; type Props = {||}; export default function Tree(props: Props) { + const selectedElementContext = useContext(SelectedElementContext); const treeContext = useContext(TreeContext); + const listRef = useRef(); - // TODO Add key handlers for selecting previous/next element. + // Make sure a newly selected element is visible in the list. + // This is helpful for things like the owners list. + useLayoutEffect(() => { + const { index } = selectedElementContext; + if (index !== null && listRef.current != null) { + listRef.current.scrollToItem(index); + } + }, [listRef, selectedElementContext]); + + // Navigate the tree with up/down arrow keys. + useEffect(() => { + const handleKeyDown = event => { + let index; + + // eslint-disable-next-line default-case + switch (event.key) { + case 'ArrowDown': + index = selectedElementContext.index; + if (index !== null && index + 1 < treeContext.size) { + selectedElementContext.index = ((index: any): number) + 1; + } + event.preventDefault(); + break; + case 'ArrowUp': + index = selectedElementContext.index; + if (index !== null && index > 0) { + selectedElementContext.index = ((index: any): number) - 1; + } + event.preventDefault(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [selectedElementContext, treeContext]); return (
@@ -27,7 +68,7 @@ export default function Tree(props: Props) { className={styles.IconButton} title="Select an element in the page to inspect it" > - Select +
@@ -39,6 +80,7 @@ export default function Tree(props: Props) { itemCount={treeContext.size} itemData={treeContext} itemSize={20} + ref={listRef} width={width} > {Element} diff --git a/src/devtools/views/context.js b/src/devtools/views/context.js index a2584e47cdd91..13be80ef6f357 100644 --- a/src/devtools/views/context.js +++ b/src/devtools/views/context.js @@ -2,10 +2,15 @@ import { createContext } from 'react'; +import type { Bridge } from '../../types'; import type { TreeMetadataType } from '../types'; import Store from '../store'; +export const BridgeContext = createContext(((null: any): Bridge)); +// $FlowFixMe displayName is a valid attribute of React$Context +BridgeContext.displayName = 'BridgeContext'; + export const StoreContext = createContext(((null: any): Store)); // $FlowFixMe displayName is a valid attribute of React$Context StoreContext.displayName = 'StoreContext'; diff --git a/src/devtools/views/root.css b/src/devtools/views/root.css index 6e31b7a02e649..e39331633f9bb 100644 --- a/src/devtools/views/root.css +++ b/src/devtools/views/root.css @@ -3,7 +3,7 @@ --color-base00: #ffffff; --color-base01: #f3f3f3; --color-base02: #eeeeee; - --color-base03: #dadada; + --color-base03: #cfd1d5; --color-base04: #333333; --color-base05: #5a5a5a; --color-special00: #8155cb; @@ -22,6 +22,7 @@ --color-state05: #222222; --color-state06: #222222; + --color-arrow: var(--color-special07); --color-component: var(--color-special00); --color-tree-tag: var(--color-base04); --color-tree-attr-name: var(--color-special06); @@ -37,8 +38,12 @@ Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol; --font-size-tree-compact: 12px; --font-size-tree-normal: 14px; + --line-height-compact: 20px; + --line-height-normal: 24px; } * { box-sizing: border-box; + + -webkit-font-smoothing: antialiased; } diff --git a/src/hook.js b/src/hook.js index 0f0e3e57491f4..a0fe7b066c232 100644 --- a/src/hook.js +++ b/src/hook.js @@ -67,11 +67,11 @@ export function installHook(target: any): Hook { } catch (err) {} } + let uidCounter = 0; + function inject(renderer) { - const id = Math.random() - .toString(16) - .slice(2); - renderers[id] = renderer; + const id = ++uidCounter; + renderers.set(id, renderer); const reactBuildType = hasDetectedBadDCE ? 'deadcode' @@ -125,8 +125,9 @@ export function installHook(target: any): Hook { function onCommitFiberUnmount(rendererID, fiber) { // TODO: can we use hook for roots too? - if (rendererInterfaces[rendererID]) { - rendererInterfaces[rendererID].handleCommitFiberUnmount(fiber); + const rendererInterface = rendererInterfaces.get(rendererID); + if (rendererInterface != null) { + rendererInterface.handleCommitFiberUnmount(fiber); } } @@ -143,16 +144,17 @@ export function installHook(target: any): Hook { } else if (isKnownRoot && isUnmounting) { mountedRoots.delete(root); } - if (rendererInterfaces[rendererID]) { - rendererInterfaces[rendererID].handleCommitFiberRoot(root); + const rendererInterface = rendererInterfaces.get(rendererID); + if (rendererInterface != null) { + rendererInterface.handleCommitFiberRoot(root); } } // TODO: More meaningful names for "rendererInterfaces" and "renderers". const fiberRoots = {}; - const rendererInterfaces = {}; + const rendererInterfaces = new Map(); const listeners = {}; - const renderers = {}; + const renderers = new Map(); const hook: Hook = { rendererInterfaces, diff --git a/src/hydration.js b/src/hydration.js new file mode 100644 index 0000000000000..80f2b75bbd969 --- /dev/null +++ b/src/hydration.js @@ -0,0 +1,216 @@ +// @flow + +export const meta = { + name: Symbol('name'), + type: Symbol('type'), + inspected: Symbol('inspected'), + meta: Symbol('meta'), + proto: Symbol('proto'), +}; + +// This threshold determines the depth at which the bridge "dehydrates" nested data. +// Dehydration means that we don't serialize the data for e.g. postMessage or stringify, +// unless the frontend explicitly requests it (e.g. a user clicks to expand a props object). +// We tried reducing this value from 2 to 1 to improve performance: +// https://github.com/facebook/react-devtools/issues/1200 +// But this caused problems with the Profiler's interaction tracing output. +// Because React mutates Fibers, profiling data that is dehydrated for old commits– +// will not be available later from within the Profiler. +// This impacts props/state as well as Interactions. +// https://github.com/facebook/react-devtools/issues/1262 +const LEVEL_THRESHOLD = 6; + +/** + * Get a enhanced/artificial type string based on the object instance + */ +function getPropType(data: Object): string | null { + if (!data) { + return null; + } + const type = typeof data; + + if (type === 'object') { + if (data._reactFragment) { + return 'react_fragment'; + } + if (Array.isArray(data)) { + return 'array'; + } + if (ArrayBuffer.isView(data)) { + if (data instanceof DataView) { + return 'data_view'; + } + return 'typed_array'; + } + if (data instanceof ArrayBuffer) { + return 'array_buffer'; + } + if (typeof data[Symbol.iterator] === 'function') { + return 'iterator'; + } + if (Object.prototype.toString.call(data) === '[object Date]') { + return 'date'; + } + } + + return type; +} + +/** + * Generate the dehydrated metadata for complex object instances + */ +function createDehydrated( + type: string, + data: Object, + cleaned: Array>, + path: Array +): Object { + const meta = {}; + + if (type === 'array' || type === 'typed_array') { + meta.length = data.length; + } + if (type === 'iterator' || type === 'typed_array') { + meta.readOnly = true; + } + + cleaned.push(path); + + return { + type, + meta, + name: + !data.constructor || data.constructor.name === 'Object' + ? '' + : data.constructor.name, + }; +} + +/** + * Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep). + * The paths of the stripped out objects are appended to the `cleaned` list. + * On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into + * an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object. + * + * Input: {"some": {"attr": fn()}, "other": AnInstance} + * Output: { + * "some": { + * "attr": {"name": the fn.name, type: "function"} + * }, + * "other": { + * "name": "AnInstance", + * "type": "object", + * }, + * } + * and cleaned = [["some", "attr"], ["other"]] + */ +export function dehydrate( + data: Object, + cleaned: Array>, + path?: Array = [], + level?: number = 0 +): string | Object { + const type = getPropType(data); + + switch (type) { + case 'function': + cleaned.push(path); + return { + name: data.name, + type: 'function', + }; + + case 'string': + return data.length <= 500 ? data : data.slice(0, 500) + '...'; + + // We have to do this assignment b/c Flow doesn't think "symbol" is + // something typeof would return. Error 'unexpected predicate "symbol"' + case 'symbol': + cleaned.push(path); + return { + type: 'symbol', + name: data.toString(), + }; + + // React Fragments error if you try to inspect them. + case 'react_fragment': + return 'A React Fragment'; + + // ArrayBuffers error if you try to inspect them. + case 'array_buffer': + case 'data_view': + cleaned.push(path); + return { + type, + name: type === 'data_view' ? 'DataView' : 'ArrayBuffer', + meta: { + length: data.byteLength, + uninspectable: true, + }, + }; + + case 'array': + if (level > LEVEL_THRESHOLD) { + return createDehydrated(type, data, cleaned, path); + } + return data.map((item, i) => + dehydrate(item, cleaned, path.concat([i]), level + 1) + ); + + case 'typed_array': + case 'iterator': + return createDehydrated(type, data, cleaned, path); + case 'date': + cleaned.push(path); + return { + name: data.toString(), + type: 'date', + meta: { + uninspectable: true, + }, + }; + case 'object': + if ( + level > LEVEL_THRESHOLD || + (data.constructor && + typeof data.constructor === 'function' && + data.constructor.name !== 'Object') + ) { + return createDehydrated(type, data, cleaned, path); + } else { + const res = {}; + for (let name in data) { + res[name] = dehydrate( + data[name], + cleaned, + path.concat([name]), + level + 1 + ); + } + return res; + } + + default: + return data; + } +} + +export function hydrate(data: Object, cleaned: Array>): Object { + cleaned.forEach((path: Array) => { + const last = path.pop(); + const reduced: Object = path.reduce( + (object: Object, attr: string) => (object ? object[attr] : (null: any)), + data + ); + if (!reduced || !reduced[last]) { + return; + } + const replace: { [key: Symbol]: boolean | string } = {}; + replace[meta.name] = reduced[last].name; + replace[meta.type] = reduced[last].type; + replace[meta.meta] = reduced[last].meta; + replace[meta.inspected] = false; + reduced[last] = replace; + }); + return data; +} diff --git a/src/utils.js b/src/utils.js index 9f7e116527523..fa90f29d39bd2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,11 @@ // @flow +let uidCounter: number = 0; + +export function getUID(): number { + return ++uidCounter; +} + export function utfDecodeString(array: Uint8Array): string { let string = ''; const { length } = array; diff --git a/yarn.lock b/yarn.lock index 7a18f52559cc1..d5acfba0c77d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8411,6 +8411,11 @@ react-dom@^16.8.0-alpha.1: prop-types "^15.6.2" scheduler "^0.13.0-alpha.1" +react-is@^16.8.0-alpha.1: + version "16.8.0-alpha.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.0-alpha.1.tgz#ac1aed207d6040f002b645af36702edf9ce2c40d" + integrity sha512-Gsh2u4ovhS2DY6fWgie/av5vzrIfW6P0lgWAsAQp9DjOImE0fJ26FfEdpFXtYBwi5s2krT9z0xvcQKvQsi4ekw== + react-portal@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899"