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

Initial pass at properties panel. Lots of TODOs remaining.

Brian Vaughn committed Feb 4, 2019 at 17:49 UTC b56bc1a62604c34b1e758f2a68c03b770c1ef6e3
34 files changed +1847 -113
package.json
+1
@@ -88,6 +88,7 @@
88 "react": "^16.8.0-alpha.1",
89 "react-color": "^2.11.7",
90 "react-dom": "^16.8.0-alpha.1",
91 + "react-is": "^16.8.0-alpha.1",
92 "react-portal": "^3.1.0",
93 "react-virtualized-auto-sizer": "^1.0.2",
94 "react-window": "^1.5.1",
shells/dev/app/App.js
+3 -1
@@ -1,8 +1,9 @@
1 // @flow
2
3 import React from 'react';
4 -import List from './ToDoList';
4 import ElementTypes from './ElementTypes';
5 +import InspectableElements from './InspectableElements';
6 +import List from './ToDoList';
7 import styles from './App.css';
8
9 export default function App() {
@@ -10,6 +11,7 @@ export default function App() {
11 <div className={styles.App}>
12 <List />
13 <ElementTypes />
14 + <InspectableElements />
15 </div>
16 );
17 }
shells/dev/app/InspectableElements/FunctionWithState.js new
+16
@@ -0,0 +1,16 @@
1 +// @flow
2 +
3 +import React, { useCallback, useState } from 'react';
4 +
5 +type Props = {|
6 + initialCount: number,
7 +|};
8 +
9 +export default function FunctionWithState({ initialCount }: Props) {
10 + const [count, setCount] = useState(initialCount);
11 + const handleClick = useCallback(() => {
12 + setCount(count => count + 1);
13 + });
14 +
15 + return <button onClick={handleClick}>Count {count}</button>;
16 +}
shells/dev/app/InspectableElements/InspectableElements.js new
+14
@@ -0,0 +1,14 @@
1 +// @flow
2 +
3 +import React, { Fragment } from 'react';
4 +import FunctionWithState from './FunctionWithState';
5 +import NestedProps from './NestedProps';
6 +
7 +export default function InspectableElements() {
8 + return (
9 + <Fragment>
10 + <FunctionWithState initialCount={1} />
11 + <NestedProps />
12 + </Fragment>
13 + );
14 +}
shells/dev/app/InspectableElements/NestedProps.js new
+32
@@ -0,0 +1,32 @@
1 +// @flow
2 +
3 +import React from 'react';
4 +
5 +export default function ObjectProps() {
6 + return (
7 + <ChildComponent
8 + object={{
9 + outer: {
10 + inner: {
11 + string: 'abc',
12 + number: 123,
13 + boolean: true,
14 + },
15 + },
16 + }}
17 + array={['first', 'second', 'third']}
18 + objectInArray={[
19 + {
20 + string: 'abc',
21 + number: 123,
22 + boolean: true,
23 + },
24 + ]}
25 + arrayInObject={{ array: ['first', 'second', 'third'] }}
26 + />
27 + );
28 +}
29 +
30 +function ChildComponent(props: any) {
31 + return null;
32 +}
shells/dev/app/InspectableElements/index.js new
+5
@@ -0,0 +1,5 @@
1 +// @flow
2 +
3 +import InspectableElements from './InspectableElements';
4 +
5 +export default InspectableElements;
src/backend/ReactDebugHooks.js new
+573
@@ -0,0 +1,573 @@
1 +// @flow
2 +
3 +// This file was forked from the React GitHub repo:
4 +// https://github.com/facebook/react/blob/master/packages/react-debug-tools/src/ReactDebugHooks.js
5 +// It has been modified slightly though to account for "shared" imports and different lint configs.
6 +// I've also removed some of the Flow types that don't exist in DevTools.
7 +// TODO Remove this fork and use the NPM version of this package once it's released.
8 +
9 +import ErrorStackParser from 'error-stack-parser';
10 +
11 +type Fiber = any;
12 +type Hook = any;
13 +
14 +// HACK: These values are copied from attachRendererFiber
15 +// In the future, the react-debug-hooks package will be published to NPM,
16 +// and be locked to a specific range of react versions
17 +// For now we are just hard-coding the current/latest versions.
18 +const ContextProvider = 10;
19 +const ForwardRef = 11;
20 +const FunctionComponent = 0;
21 +const SimpleMemoComponent = 15;
22 +
23 +// Used to track hooks called during a render
24 +
25 +type HookLogEntry = {
26 + primitive: string,
27 + stackError: Error,
28 + value: mixed,
29 +};
30 +
31 +let hookLog: Array<HookLogEntry> = [];
32 +
33 +// Primitives
34 +
35 +type BasicStateAction<S> = (S => S) | S;
36 +
37 +type Dispatch<A> = A => void;
38 +
39 +let primitiveStackCache: null | Map<string, Array<any>> = null;
40 +
41 +function getPrimitiveStackCache(): Map<string, Array<any>> {
42 + // This initializes a cache of all primitive hooks so that the top
43 + // most stack frames added by calling the primitive hook can be removed.
44 + if (primitiveStackCache === null) {
45 + const cache = new Map();
46 + let readHookLog;
47 + try {
48 + // Use all hooks here to add them to the hook log.
49 + Dispatcher.useContext(({ _currentValue: null }: any));
50 + Dispatcher.useState(null);
51 + Dispatcher.useReducer((s, a) => s, null);
52 + Dispatcher.useRef(null);
53 + Dispatcher.useLayoutEffect(() => {});
54 + Dispatcher.useEffect(() => {});
55 + Dispatcher.useImperativeHandle(undefined, () => null);
56 + Dispatcher.useCallback(() => {});
57 + Dispatcher.useMemo(() => null);
58 + Dispatcher.useDebugValue(null);
59 + } finally {
60 + readHookLog = hookLog;
61 + hookLog = [];
62 + }
63 + for (let i = 0; i < readHookLog.length; i++) {
64 + const hook = readHookLog[i];
65 + cache.set(hook.primitive, ErrorStackParser.parse(hook.stackError));
66 + }
67 + primitiveStackCache = cache;
68 + }
69 + return primitiveStackCache;
70 +}
71 +
72 +let currentHook: null | Hook = null;
73 +function nextHook(): null | Hook {
74 + const hook = currentHook;
75 + if (hook !== null) {
76 + currentHook = hook.next;
77 + }
78 + return hook;
79 +}
80 +
81 +function readContext<T>(
82 + context: any,
83 + observedBits: void | number | boolean
84 +): T {
85 + // For now we don't expose readContext usage in the hooks debugging info.
86 + return context._currentValue;
87 +}
88 +
89 +function useContext<T>(context: any, observedBits: void | number | boolean): T {
90 + hookLog.push({
91 + primitive: 'Context',
92 + stackError: new Error(),
93 + value: context._currentValue,
94 + });
95 + return context._currentValue;
96 +}
97 +
98 +function useState<S>(
99 + initialState: (() => S) | S
100 +): [S, Dispatch<BasicStateAction<S>>] {
101 + const hook = nextHook();
102 + const state: S =
103 + hook !== null
104 + ? hook.memoizedState
105 + : typeof initialState === 'function'
106 + ? (initialState: any)()
107 + : initialState;
108 + hookLog.push({ primitive: 'State', stackError: new Error(), value: state });
109 + return [state, (action: BasicStateAction<S>) => {}];
110 +}
111 +
112 +function useReducer<S, A>(
113 + reducer: (S, A) => S,
114 + initialState: S,
115 + initialAction: A | void | null
116 +): [S, Dispatch<A>] {
117 + const hook = nextHook();
118 + const state = hook !== null ? hook.memoizedState : initialState;
119 + hookLog.push({
120 + primitive: 'Reducer',
121 + stackError: new Error(),
122 + value: state,
123 + });
124 + return [state, (action: A) => {}];
125 +}
126 +
127 +function useRef<T>(initialValue: T): { current: T } {
128 + const hook = nextHook();
129 + const ref = hook !== null ? hook.memoizedState : { current: initialValue };
130 + hookLog.push({
131 + primitive: 'Ref',
132 + stackError: new Error(),
133 + value: ref.current,
134 + });
135 + return ref;
136 +}
137 +
138 +function useLayoutEffect(
139 + create: () => mixed,
140 + inputs: Array<mixed> | void | null
141 +): void {
142 + nextHook();
143 + hookLog.push({
144 + primitive: 'LayoutEffect',
145 + stackError: new Error(),
146 + value: create,
147 + });
148 +}
149 +
150 +function useEffect(
151 + create: () => mixed,
152 + inputs: Array<mixed> | void | null
153 +): void {
154 + nextHook();
155 + hookLog.push({ primitive: 'Effect', stackError: new Error(), value: create });
156 +}
157 +
158 +function useImperativeHandle<T>(
159 + ref: { current: T | null } | ((inst: T | null) => mixed) | null | void,
160 + create: () => T,
161 + inputs: Array<mixed> | void | null
162 +): void {
163 + nextHook();
164 + // We don't actually store the instance anywhere if there is no ref callback
165 + // and if there is a ref callback it might not store it but if it does we
166 + // have no way of knowing where. So let's only enable introspection of the
167 + // ref itself if it is using the object form.
168 + let instance = undefined;
169 + if (ref !== null && typeof ref === 'object') {
170 + instance = ref.current;
171 + }
172 + hookLog.push({
173 + primitive: 'ImperativeHandle',
174 + stackError: new Error(),
175 + value: instance,
176 + });
177 +}
178 +
179 +function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
180 + const hook = nextHook();
181 + hookLog.push({
182 + primitive: 'Callback',
183 + stackError: new Error(),
184 + value: hook !== null ? hook.memoizedState[0] : callback,
185 + });
186 + return callback;
187 +}
188 +
189 +function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
190 + hookLog.push({
191 + primitive: 'DebugValue',
192 + stackError: new Error(),
193 + value: typeof formatterFn === 'function' ? formatterFn(value) : value,
194 + });
195 +}
196 +
197 +function useMemo<T>(
198 + nextCreate: () => T,
199 + inputs: Array<mixed> | void | null
200 +): T {
201 + const hook = nextHook();
202 + const value = hook !== null ? hook.memoizedState[0] : nextCreate();
203 + hookLog.push({ primitive: 'Memo', stackError: new Error(), value });
204 + return value;
205 +}
206 +
207 +const Dispatcher = {
208 + readContext,
209 + useCallback,
210 + useContext,
211 + useEffect,
212 + useImperativeHandle,
213 + useDebugValue,
214 + useLayoutEffect,
215 + useMemo,
216 + useReducer,
217 + useRef,
218 + useState,
219 +};
220 +
221 +// Inspect
222 +
223 +type ReactCurrentDispatcher = {
224 + current: null | typeof Dispatcher,
225 +};
226 +
227 +type HooksNode = {
228 + name: string,
229 + value: mixed,
230 + subHooks: Array<HooksNode>,
231 +};
232 +type HooksTree = Array<HooksNode>;
233 +
234 +// Don't assume
235 +//
236 +// We can't assume that stack frames are nth steps away from anything.
237 +// E.g. we can't assume that the root call shares all frames with the stack
238 +// of a hook call. A simple way to demonstrate this is wrapping `new Error()`
239 +// in a wrapper constructor like a polyfill. That'll add an extra frame.
240 +// Similar things can happen with the call to the dispatcher. The top frame
241 +// may not be the primitive. Likewise the primitive can have fewer stack frames
242 +// such as when a call to useState got inlined to use dispatcher.useState.
243 +//
244 +// We also can't assume that the last frame of the root call is the same
245 +// frame as the last frame of the hook call because long stack traces can be
246 +// truncated to a stack trace limit.
247 +
248 +let mostLikelyAncestorIndex = 0;
249 +
250 +function findSharedIndex(hookStack, rootStack, rootIndex) {
251 + const source = rootStack[rootIndex].source;
252 + hookSearch: for (let i = 0; i < hookStack.length; i++) {
253 + if (hookStack[i].source === source) {
254 + // This looks like a match. Validate that the rest of both stack match up.
255 + for (
256 + let a = rootIndex + 1, b = i + 1;
257 + a < rootStack.length && b < hookStack.length;
258 + a++, b++
259 + ) {
260 + if (hookStack[b].source !== rootStack[a].source) {
261 + // If not, give up and try a different match.
262 + continue hookSearch;
263 + }
264 + }
265 + return i;
266 + }
267 + }
268 + return -1;
269 +}
270 +
271 +function findCommonAncestorIndex(rootStack, hookStack) {
272 + let rootIndex = findSharedIndex(
273 + hookStack,
274 + rootStack,
275 + mostLikelyAncestorIndex
276 + );
277 + if (rootIndex !== -1) {
278 + return rootIndex;
279 + }
280 + // If the most likely one wasn't a hit, try any other frame to see if it is shared.
281 + // If that takes more than 5 frames, something probably went wrong.
282 + for (let i = 0; i < rootStack.length && i < 5; i++) {
283 + rootIndex = findSharedIndex(hookStack, rootStack, i);
284 + if (rootIndex !== -1) {
285 + mostLikelyAncestorIndex = i;
286 + return rootIndex;
287 + }
288 + }
289 + return -1;
290 +}
291 +
292 +function isReactWrapper(functionName, primitiveName) {
293 + if (!functionName) {
294 + return false;
295 + }
296 + const expectedPrimitiveName = 'use' + primitiveName;
297 + if (functionName.length < expectedPrimitiveName.length) {
298 + return false;
299 + }
300 + return (
301 + functionName.lastIndexOf(expectedPrimitiveName) ===
302 + functionName.length - expectedPrimitiveName.length
303 + );
304 +}
305 +
306 +function findPrimitiveIndex(hookStack, hook) {
307 + const stackCache = getPrimitiveStackCache();
308 + const primitiveStack = stackCache.get(hook.primitive);
309 + if (primitiveStack === undefined) {
310 + return -1;
311 + }
312 + for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) {
313 + if (primitiveStack[i].source !== hookStack[i].source) {
314 + // If the next two frames are functions called `useX` then we assume that they're part of the
315 + // wrappers that the React packager or other packages adds around the dispatcher.
316 + if (
317 + i < hookStack.length - 1 &&
318 + isReactWrapper(hookStack[i].functionName, hook.primitive)
319 + ) {
320 + i++;
321 + }
322 + if (
323 + i < hookStack.length - 1 &&
324 + isReactWrapper(hookStack[i].functionName, hook.primitive)
325 + ) {
326 + i++;
327 + }
328 + return i;
329 + }
330 + }
331 + return -1;
332 +}
333 +
334 +function parseTrimmedStack(rootStack, hook) {
335 + // Get the stack trace between the primitive hook function and
336 + // the root function call. I.e. the stack frames of custom hooks.
337 + const hookStack = ErrorStackParser.parse(hook.stackError);
338 + const rootIndex = findCommonAncestorIndex(rootStack, hookStack);
339 + const primitiveIndex = findPrimitiveIndex(hookStack, hook);
340 + if (
341 + rootIndex === -1 ||
342 + primitiveIndex === -1 ||
343 + rootIndex - primitiveIndex < 2
344 + ) {
345 + // Something went wrong. Give up.
346 + return null;
347 + }
348 + return hookStack.slice(primitiveIndex, rootIndex - 1);
349 +}
350 +
351 +function parseCustomHookName(functionName: void | string): string {
352 + if (!functionName) {
353 + return '';
354 + }
355 + let startIndex = functionName.lastIndexOf('.');
356 + if (startIndex === -1) {
357 + startIndex = 0;
358 + }
359 + if (functionName.substr(startIndex, 3) === 'use') {
360 + startIndex += 3;
361 + }
362 + return functionName.substr(startIndex);
363 +}
364 +
365 +function buildTree(rootStack, readHookLog): HooksTree {
366 + const rootChildren = [];
367 + let prevStack = null;
368 + let levelChildren = rootChildren;
369 + const stackOfChildren = [];
370 + for (let i = 0; i < readHookLog.length; i++) {
371 + const hook = readHookLog[i];
372 + const stack = parseTrimmedStack(rootStack, hook);
373 + if (stack !== null) {
374 + // Note: The indices 0 <= n < length-1 will contain the names.
375 + // The indices 1 <= n < length will contain the source locations.
376 + // That's why we get the name from n - 1 and don't check the source
377 + // of index 0.
378 + let commonSteps = 0;
379 + if (prevStack !== null) {
380 + // Compare the current level's stack to the new stack.
381 + while (commonSteps < stack.length && commonSteps < prevStack.length) {
382 + const stackSource = stack[stack.length - commonSteps - 1].source;
383 + const prevSource =
384 + prevStack[prevStack.length - commonSteps - 1].source;
385 + if (stackSource !== prevSource) {
386 + break;
387 + }
388 + commonSteps++;
389 + }
390 + // Pop back the stack as many steps as were not common.
391 + for (let j = prevStack.length - 1; j > commonSteps; j--) {
392 + levelChildren = stackOfChildren.pop();
393 + }
394 + }
395 + // The remaining part of the new stack are custom hooks. Push them
396 + // to the tree.
397 + for (let j = stack.length - commonSteps - 1; j >= 1; j--) {
398 + const children = [];
399 + levelChildren.push({
400 + name: parseCustomHookName(stack[j - 1].functionName),
401 + value: undefined,
402 + subHooks: children,
403 + });
404 + stackOfChildren.push(levelChildren);
405 + levelChildren = children;
406 + }
407 + prevStack = stack;
408 + }
409 + levelChildren.push({
410 + name: hook.primitive,
411 + value: hook.value,
412 + subHooks: [],
413 + });
414 + }
415 +
416 + // Associate custom hook values (useDebugValue() hook entries) with the correct hooks.
417 + rollupDebugValues(rootChildren, null);
418 +
419 + return rootChildren;
420 +}
421 +
422 +// Custom hooks support user-configurable labels (via the useDebugValue() hook).
423 +// That hook adds the user-provided values to the hooks tree.
424 +// This method removes those values (so they don't appear in DevTools),
425 +// and bubbles them up to the "value" attribute of their parent custom hook.
426 +function rollupDebugValues(
427 + hooksTree: HooksTree,
428 + parentHooksNode: HooksNode | null
429 +): void {
430 + const debugValueHooksNodes: Array<HooksNode> = [];
431 +
432 + for (let i = 0; i < hooksTree.length; i++) {
433 + const hooksNode = hooksTree[i];
434 + if (hooksNode.name === 'DebugValue' && hooksNode.subHooks.length === 0) {
435 + hooksTree.splice(i, 1);
436 + i--;
437 + debugValueHooksNodes.push(hooksNode);
438 + } else {
439 + rollupDebugValues(hooksNode.subHooks, hooksNode);
440 + }
441 + }
442 +
443 + // Bubble debug value labels to their parent custom hook.
444 + // If there is no parent hook, just ignore them.
445 + // (We may warn about this in the future.)
446 + if (parentHooksNode !== null) {
447 + if (debugValueHooksNodes.length === 1) {
448 + parentHooksNode.value = debugValueHooksNodes[0].value;
449 + } else if (debugValueHooksNodes.length > 1) {
450 + parentHooksNode.value = debugValueHooksNodes.map(({ value }) => value);
451 + }
452 + }
453 +}
454 +
455 +export function inspectHooks<Props>(
456 + renderFunction: Props => React$Node,
457 + props: Props,
458 + currentDispatcher: ReactCurrentDispatcher
459 +): HooksTree {
460 + const previousDispatcher = currentDispatcher.current;
461 + let readHookLog;
462 + currentDispatcher.current = Dispatcher;
463 + let ancestorStackError;
464 + try {
465 + ancestorStackError = new Error();
466 + renderFunction(props);
467 + } finally {
468 + readHookLog = hookLog;
469 + hookLog = [];
470 + currentDispatcher.current = previousDispatcher;
471 + }
472 + const rootStack = ErrorStackParser.parse(ancestorStackError);
473 + return buildTree(rootStack, readHookLog);
474 +}
475 +
476 +function setupContexts(contextMap: Map<any, any>, fiber: Fiber) {
477 + let current = fiber;
478 + while (current) {
479 + if (current.tag === ContextProvider) {
480 + const providerType: any = current.type;
481 + const context: any = providerType._context;
482 + if (!contextMap.has(context)) {
483 + // Store the current value that we're going to restore later.
484 + contextMap.set(context, context._currentValue);
485 + // Set the inner most provider value on the context.
486 + context._currentValue = current.memoizedProps.value;
487 + }
488 + }
489 + current = current.return;
490 + }
491 +}
492 +
493 +function restoreContexts(contextMap: Map<any, any>) {
494 + contextMap.forEach((value, context) => (context._currentValue = value));
495 +}
496 +
497 +function inspectHooksOfForwardRef<Props, Ref>(
498 + renderFunction: (Props, Ref) => React$Node,
499 + props: Props,
500 + ref: Ref,
501 + currentDispatcher: ReactCurrentDispatcher
502 +): HooksTree {
503 + const previousDispatcher = currentDispatcher.current;
504 + let readHookLog;
505 + currentDispatcher.current = Dispatcher;
506 + let ancestorStackError;
507 + try {
508 + ancestorStackError = new Error();
509 + renderFunction(props, ref);
510 + } finally {
511 + readHookLog = hookLog;
512 + hookLog = [];
513 + currentDispatcher.current = previousDispatcher;
514 + }
515 + const rootStack = ErrorStackParser.parse(ancestorStackError);
516 + return buildTree(rootStack, readHookLog);
517 +}
518 +
519 +function resolveDefaultProps(Component, baseProps) {
520 + if (Component && Component.defaultProps) {
521 + // Resolve default props. Taken from ReactElement
522 + const props = Object.assign({}, baseProps);
523 + const defaultProps = Component.defaultProps;
524 + for (const propName in defaultProps) {
525 + if (props[propName] === undefined) {
526 + props[propName] = defaultProps[propName];
527 + }
528 + }
529 + return props;
530 + }
531 + return baseProps;
532 +}
533 +
534 +export function inspectHooksOfFiber(
535 + fiber: Fiber,
536 + currentDispatcher: ReactCurrentDispatcher
537 +) {
538 + if (
539 + fiber.tag !== FunctionComponent &&
540 + fiber.tag !== SimpleMemoComponent &&
541 + fiber.tag !== ForwardRef
542 + ) {
543 + throw new Error(
544 + 'Unknown Fiber. Needs to be a function component to inspect hooks.'
545 + );
546 + }
547 + // Warm up the cache so that it doesn't consume the currentHook.
548 + getPrimitiveStackCache();
549 + const type = fiber.type;
550 + let props = fiber.memoizedProps;
551 + if (type !== fiber.elementType) {
552 + props = resolveDefaultProps(type, props);
553 + }
554 + // Set up the current hook so that we can step through and read the
555 + // current state from them.
556 + currentHook = (fiber.memoizedState: Hook);
557 + const contextMap = new Map();
558 + try {
559 + setupContexts(contextMap, fiber);
560 + if (fiber.tag === ForwardRef) {
561 + return inspectHooksOfForwardRef(
562 + type.render,
563 + props,
564 + fiber.ref,
565 + currentDispatcher
566 + );
567 + }
568 + return inspectHooks(type, props, currentDispatcher);
569 + } finally {
570 + currentHook = null;
571 + restoreContexts(contextMap);
572 + }
573 +}
src/backend/agent.js
+12 -11
@@ -24,12 +24,23 @@ export default class Agent extends EventEmitter {
24 addBridge(bridge: Bridge) {
25 this._bridge = bridge;
26
27 - bridge.on('shutdown', () => this.emit('shutdown'));
27 + bridge.addListener('shutdown', () => this.emit('shutdown'));
28
29 + bridge.addListener('inspectElement', this.inspectElement);
30 // TODO Listen to bridge for things like selection.
31 // bridge.on('...'), this...);
32 }
33
34 + inspectElement = ({ id, rendererID }: { id: number, rendererID: number }) => {
35 + const renderer = this._rendererInterfaces[rendererID];
36 +
37 + if (renderer == null) {
38 + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
39 + } else {
40 + this._bridge.send('inspectedElement', renderer.inspectElement(id));
41 + }
42 + };
43 +
44 setRendererInterface(
45 rendererID: RendererID,
46 rendererInterface: RendererInterface
@@ -37,16 +48,6 @@ export default class Agent extends EventEmitter {
48 this._rendererInterfaces[rendererID] = rendererInterface;
49 }
50
40 - onHookDisplayNames = (displayNames: Map<number, string>) => {
41 - debug('onHookDisplayNames', displayNames);
42 - this._bridge.send('displayNames', displayNames);
43 - };
44 -
45 - onHookKeys = (keys: Map<number, string>) => {
46 - debug('onHookKeys', keys);
47 - this._bridge.send('keys', keys);
48 - };
49 -
51 onHookOperations = (operations: Uint32Array) => {
52 debug('onHookOperations', operations);
53 this._bridge.send('operations', operations, [operations.buffer]);
src/backend/index.js
+29 -16
@@ -1,16 +1,27 @@
1 // @flow
2
3 -import type { Hook } from './types';
3 +import type { Hook, ReactRenderer, RendererInterface } from './types';
4 import Agent from './agent';
5
6 import { attach } from './renderer';
7
8 export function initBackend(hook: Hook, agent: Agent): void {
9 const subs = [
10 - hook.sub('renderer-attached', ({ id, renderer, rendererInterface }) => {
11 - agent.setRendererInterface(id, rendererInterface);
12 - rendererInterface.walkTree();
13 - }),
10 + hook.sub(
11 + 'renderer-attached',
12 + ({
13 + id,
14 + renderer,
15 + rendererInterface,
16 + }: {
17 + id: number,
18 + renderer: ReactRenderer,
19 + rendererInterface: RendererInterface,
20 + }) => {
21 + agent.setRendererInterface(id, rendererInterface);
22 + rendererInterface.walkTree();
23 + }
24 + ),
25
26 hook.sub('operations', agent.onHookOperations),
27 hook.sub('rootCommitted', agent.onHookRootCommitted),
@@ -18,9 +29,9 @@ export function initBackend(hook: Hook, agent: Agent): void {
29 // TODO Add additional subscriptions required for profiling mode
30 ];
31
21 - const attachRenderer = (id, renderer) => {
32 + const attachRenderer = (id: number, renderer: ReactRenderer) => {
33 const rendererInterface = attach(hook, id, renderer);
23 - hook.rendererInterfaces[id] = rendererInterface;
34 + hook.rendererInterfaces.set(id, rendererInterface);
35 hook.emit('renderer-attached', {
36 id,
37 renderer,
@@ -29,23 +40,25 @@ export function initBackend(hook: Hook, agent: Agent): void {
40 };
41
42 // Connect renderers that have already injected themselves.
32 - for (let id in hook.renderers) {
33 - const renderer = hook.renderers[id];
43 + hook.renderers.forEach((renderer, id) => {
44 attachRenderer(id, renderer);
35 - }
45 + });
46
47 // Connect any new renderers that injected themselves.
38 - hook.on('renderer', ({ id, renderer }) => {
39 - attachRenderer(id, renderer);
40 - });
48 + hook.on(
49 + 'renderer',
50 + ({ id, renderer }: { id: number, renderer: ReactRenderer }) => {
51 + attachRenderer(id, renderer);
52 + }
53 + );
54
55 hook.emit('react-devtools', agent);
56 hook.reactDevtoolsAgent = agent;
57 agent.addListener('shutdown', () => {
58 subs.forEach(fn => fn());
46 - for (let id in hook.rendererInterfaces) {
47 - hook.rendererInterfaces[id].cleanup();
48 - }
59 + hook.rendererInterfaces.forEach(rendererInterface => {
60 + rendererInterface.cleanup();
61 + });
62 hook.reactDevtoolsAgent = null;
63 });
64 }
src/backend/renderer.js
+270 -13
@@ -12,13 +12,15 @@ import {
12 ElementTypeSuspense,
13 } from 'src/devtools/types';
14 import { utfEncodeString } from '../utils';
15 -import { getDisplayName } from './utils';
15 +import { cleanForBridge, cleanPropsForBridge, getDisplayName } from './utils';
16 import {
17 __DEBUG__,
18 TREE_OPERATION_ADD,
19 TREE_OPERATION_REMOVE,
20 TREE_OPERATION_RESET_CHILDREN,
21 } from '../constants';
22 +import { getUID } from '../utils';
23 +import { inspectHooksOfFiber } from './ReactDebugHooks';
24
25 import type {
26 Fiber,
@@ -27,6 +29,7 @@ import type {
29 FiberData,
30 RendererInterface,
31 } from './types';
32 +import type { InspectedElement } from 'src/devtools/types';
33
34 function getInternalReactConstants(version) {
35 const ReactSymbols = {
@@ -51,7 +54,9 @@ function getInternalReactConstants(version) {
54 };
55
56 const ReactTypeOfSideEffect = {
54 - PerformedWork: 1,
57 + NoEffect: 0b00,
58 + PerformedWork: 0b01,
59 + Placement: 0b10,
60 };
61
62 let ReactTypeOfWork;
@@ -146,7 +151,7 @@ function getInternalReactConstants(version) {
151
152 export function attach(
153 hook: Hook,
149 - rendererID: string,
154 + rendererID: number,
155 renderer: ReactRenderer
156 ): RendererInterface {
157 const {
@@ -154,7 +159,7 @@ export function attach(
159 ReactSymbols,
160 ReactTypeOfSideEffect,
161 } = getInternalReactConstants(renderer.version);
157 - const { PerformedWork } = ReactTypeOfSideEffect;
162 + const { NoEffect, PerformedWork, Placement } = ReactTypeOfSideEffect;
163 const {
164 FunctionComponent,
165 ClassComponent,
@@ -212,8 +217,6 @@ export function attach(
217 }
218 };
219
215 - const primaryFibers: WeakSet<Fiber> = new WeakSet();
216 -
220 // Keep this function in sync with getDataForFiber()
221 function shouldFilterFiber(fiber: Fiber): boolean {
222 const { type, tag } = fiber;
@@ -443,12 +446,15 @@ export function attach(
446 return fiber;
447 }
448
446 - let uidCounter: number = 0;
447 - const fiberToIDMap: WeakMap<Fiber, number> = new WeakMap();
449 + const fiberToIDMap: Map<Fiber, number> = new Map();
450 + const idToFiberMap: Map<number, Fiber> = new Map();
451 + const primaryFibers: Set<Fiber> = new Set();
452
453 function getFiberID(primaryFiber: Fiber): number {
454 if (!fiberToIDMap.has(primaryFiber)) {
451 - fiberToIDMap.set(primaryFiber, ++uidCounter);
455 + const id = getUID();
456 + fiberToIDMap.set(primaryFiber, id);
457 + idToFiberMap.set(id, primaryFiber);
458 }
459 return ((fiberToIDMap.get(primaryFiber): any): number);
460 }
@@ -509,6 +515,13 @@ export function attach(
515 }
516
517 function flushPendingEvents(root: Object): void {
518 + // Identify which renderer this update is coming from.
519 + // This enables roots to be mapped to renderers,
520 + // Which in turn enables fiber props, states, and hooks to be inspected.
521 + const idArray = new Uint32Array(1);
522 + idArray[0] = rendererID;
523 + addOperation(idArray, true);
524 +
525 // Let the frontend know about tree operations.
526 hook.emit('operations', pendingOperations);
527 pendingOperations = new Uint32Array(0);
@@ -575,8 +588,8 @@ export function attach(
588 function enqueueUnmount(fiber) {
589 const isRoot = fiber.tag === HostRoot;
590 const primaryFiber = getPrimaryFiber(fiber);
591 + const id = getFiberID(primaryFiber);
592 if (isRoot) {
579 - const id = getFiberID(getPrimaryFiber(fiber));
593 const operation = new Uint32Array(2);
594 operation[0] = TREE_OPERATION_REMOVE;
595 operation[1] = id;
@@ -585,15 +598,15 @@ export function attach(
598 // Non-root fibers are deleted during the commit phase.
599 // They are deleted in the child-first order. However
600 // DevTools currently expects deletions to be parent-first.
588 - // This is why we unshift deletions rather than push them.
589 - const id = getFiberID(getPrimaryFiber(fiber));
601 + // This is why we unshift deletions rather tha
602 const operation = new Uint32Array(2);
603 operation[0] = TREE_OPERATION_REMOVE;
604 operation[1] = id;
605 addOperation(operation, true);
606 }
595 - primaryFibers.delete(primaryFiber);
607 + idToFiberMap.delete(id);
608 fiberToIDMap.delete(primaryFiber);
609 + primaryFibers.delete(primaryFiber);
610 }
611
612 function mountFiber(fiber: Fiber, parentFiber: Fiber | null) {
@@ -820,11 +833,255 @@ export function attach(
833 return null;
834 }
835
836 + const MOUNTING = 1;
837 + const MOUNTED = 2;
838 + const UNMOUNTED = 3;
839 +
840 + // This function is copied from React and should be kept in sync:
841 + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js
842 + function isFiberMountedImpl(fiber: Fiber): number {
843 + let node = fiber;
844 + if (!fiber.alternate) {
845 + // If there is no alternate, this might be a new tree that isn't inserted
846 + // yet. If it is, then it will have a pending insertion effect on it.
847 + if ((node.effectTag & Placement) !== NoEffect) {
848 + return MOUNTING;
849 + }
850 + while (node.return) {
851 + node = node.return;
852 + if ((node.effectTag & Placement) !== NoEffect) {
853 + return MOUNTING;
854 + }
855 + }
856 + } else {
857 + while (node.return) {
858 + node = node.return;
859 + }
860 + }
861 + if (node.tag === HostRoot) {
862 + // TODO: Check if this was a nested HostRoot when used with
863 + // renderContainerIntoSubtree.
864 + return MOUNTED;
865 + }
866 + // If we didn't hit the root, that means that we're in an disconnected tree
867 + // that has been unmounted.
868 + return UNMOUNTED;
869 + }
870 +
871 + // This function is copied from React and should be kept in sync:
872 + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js
873 + // It would be nice if we updated React to inject this function directly (vs just indirectly via findDOMNode).
874 + function findCurrentFiberUsingSlowPath(fiber: Fiber): Fiber | null {
875 + let alternate = fiber.alternate;
876 + if (!alternate) {
877 + // If there is no alternate, then we only need to check if it is mounted.
878 + const state = isFiberMountedImpl(fiber);
879 + if (state === UNMOUNTED) {
880 + throw Error('Unable to find node on an unmounted component.');
881 + }
882 + if (state === MOUNTING) {
883 + return null;
884 + }
885 + return fiber;
886 + }
887 + // If we have two possible branches, we'll walk backwards up to the root
888 + // to see what path the root points to. On the way we may hit one of the
889 + // special cases and we'll deal with them.
890 + let a = fiber;
891 + let b = alternate;
892 + while (true) {
893 + let parentA = a.return;
894 + let parentB = parentA ? parentA.alternate : null;
895 + if (!parentA || !parentB) {
896 + // We're at the root.
897 + break;
898 + }
899 +
900 + // If both copies of the parent fiber point to the same child, we can
901 + // assume that the child is current. This happens when we bailout on low
902 + // priority: the bailed out fiber's child reuses the current child.
903 + if (parentA.child === parentB.child) {
904 + let child = parentA.child;
905 + while (child) {
906 + if (child === a) {
907 + // We've determined that A is the current branch.
908 + if (isFiberMountedImpl(parentA) !== MOUNTED) {
909 + throw Error('Unable to find node on an unmounted component.');
910 + }
911 + return fiber;
912 + }
913 + if (child === b) {
914 + // We've determined that B is the current branch.
915 + if (isFiberMountedImpl(parentA) !== MOUNTED) {
916 + throw Error('Unable to find node on an unmounted component.');
917 + }
918 + return alternate;
919 + }
920 + child = child.sibling;
921 + }
922 + // We should never have an alternate for any mounting node. So the only
923 + // way this could possibly happen is if this was unmounted, if at all.
924 + throw Error('Unable to find node on an unmounted component.');
925 + }
926 +
927 + if (a.return !== b.return) {
928 + // The return pointer of A and the return pointer of B point to different
929 + // fibers. We assume that return pointers never criss-cross, so A must
930 + // belong to the child set of A.return, and B must belong to the child
931 + // set of B.return.
932 + a = parentA;
933 + b = parentB;
934 + } else {
935 + // The return pointers point to the same fiber. We'll have to use the
936 + // default, slow path: scan the child sets of each parent alternate to see
937 + // which child belongs to which set.
938 + //
939 + // Search parent A's child set
940 + let didFindChild = false;
941 + let child = parentA.child;
942 + while (child) {
943 + if (child === a) {
944 + didFindChild = true;
945 + a = parentA;
946 + b = parentB;
947 + break;
948 + }
949 + if (child === b) {
950 + didFindChild = true;
951 + b = parentA;
952 + a = parentB;
953 + break;
954 + }
955 + child = child.sibling;
956 + }
957 + if (!didFindChild) {
958 + // Search parent B's child set
959 + child = parentB.child;
960 + while (child) {
961 + if (child === a) {
962 + didFindChild = true;
963 + a = parentB;
964 + b = parentA;
965 + break;
966 + }
967 + if (child === b) {
968 + didFindChild = true;
969 + b = parentB;
970 + a = parentA;
971 + break;
972 + }
973 + child = child.sibling;
974 + }
975 + if (!didFindChild) {
976 + throw Error(
977 + 'Child was not found in either parent set. This indicates a bug ' +
978 + 'in React related to the return pointer. Please file an issue.'
979 + );
980 + }
981 + }
982 + }
983 +
984 + if (a.alternate !== b) {
985 + throw Error(
986 + "Return fibers should always be each others' alternates. " +
987 + 'This error is likely caused by a bug in React. Please file an issue.'
988 + );
989 + }
990 + }
991 + // If the root is not a host container, we're in a disconnected tree. I.e.
992 + // unmounted.
993 + if (a.tag !== HostRoot) {
994 + throw Error('Unable to find node on an unmounted component.');
995 + }
996 + if (a.stateNode.current === a) {
997 + // We've determined that A is the current branch.
998 + return fiber;
999 + }
1000 + // Otherwise B has to be current branch.
1001 + return alternate;
1002 + }
1003 +
1004 + function inspectElement(id: number): InspectedElement | null {
1005 + let fiber = idToFiberMap.get(id);
1006 +
1007 + if (fiber == null) {
1008 + console.warn(`Could not find Fiber with id "${id}"`);
1009 + return null;
1010 + }
1011 +
1012 + // Find the currently mounted version of this fiber (so we don't show the wrong props and state).
1013 + fiber = findCurrentFiberUsingSlowPath(fiber);
1014 +
1015 + const {
1016 + _debugOwner,
1017 + _debugSource,
1018 + stateNode,
1019 + memoizedProps,
1020 + memoizedState,
1021 + tag,
1022 + } = ((fiber: any): Fiber);
1023 +
1024 + const usesHooks =
1025 + (tag === FunctionComponent ||
1026 + tag === SimpleMemoComponent ||
1027 + tag === ForwardRef) &&
1028 + !!memoizedState;
1029 +
1030 + const hasContext =
1031 + tag === ClassComponent ||
1032 + tag === FunctionComponent ||
1033 + tag === IncompleteClassComponent ||
1034 + tag === IndeterminateComponent;
1035 +
1036 + let owners = null;
1037 + if (_debugOwner) {
1038 + owners = [];
1039 + let owner = _debugOwner;
1040 + while (owner !== null) {
1041 + owners.push({
1042 + displayName: getDataForFiber(owner).displayName || 'Unknown',
1043 + id: getFiberID(getPrimaryFiber(owner)),
1044 + });
1045 + owner = owner._debugOwner;
1046 + }
1047 + }
1048 +
1049 + return {
1050 + id,
1051 +
1052 + // Does the current renderer support editable props/state/hooks?
1053 + canEditValues: false, // TODO
1054 +
1055 + // Inspectable properties.
1056 + // TODO Sanitize props, state, and context
1057 + context:
1058 + (hasContext &&
1059 + stateNode &&
1060 + Object.keys(stateNode.context).length > 0 &&
1061 + cleanForBridge(stateNode.context)) ||
1062 + null,
1063 + hooks: usesHooks
1064 + ? cleanForBridge(
1065 + inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any))
1066 + )
1067 + : null,
1068 + props: cleanPropsForBridge(memoizedProps),
1069 + state: usesHooks ? null : cleanForBridge(memoizedState),
1070 +
1071 + // List of owners
1072 + owners,
1073 +
1074 + // Location of component in source coude.
1075 + source: _debugSource,
1076 + };
1077 + }
1078 +
1079 return {
1080 getNativeFromReactElement,
1081 getReactElementFromNative,
1082 handleCommitFiberRoot,
1083 handleCommitFiberUnmount,
1084 + inspectElement,
1085 cleanup,
1086 walkTree,
1087 renderer,
src/backend/types.js
+24 -5
@@ -1,6 +1,6 @@
1 // @flow
2
3 -import type { ElementType } from 'src/devtools/types';
3 +import type { ElementType, InspectedElement } from 'src/devtools/types';
4
5 type BundleType =
6 | 0 // PROD
@@ -24,7 +24,9 @@ export type Interaction = {|
24 |};
25
26 export type NativeType = {};
27 -export type RendererID = string;
27 +export type RendererID = number;
28 +
29 +type Dispatcher = any;
30
31 export type ReactRenderer = {
32 findHostInstanceByFiber: (fiber: Object) => ?NativeType,
@@ -36,6 +38,9 @@ export type ReactRenderer = {
38 path: Array<string | number>,
39 value: any
40 ) => void,
41 +
42 + // Only injected by React v16.8+ in order to support hooks inspection.
43 + currentDispatcherRef?: {| current: null | Dispatcher |},
44 };
45
46 export type RendererInterface = {
@@ -43,6 +48,7 @@ export type RendererInterface = {
48 getNativeFromReactElement?: ?(component: Fiber) => ?NativeType,
49 getReactElementFromNative?: ?(component: NativeType) => ?Fiber,
50 handleCommitFiberRoot: (fiber: Object) => void,
51 + inspectElement: (id: number) => InspectedElement | null,
52 renderer: ReactRenderer | null,
53 walkTree: () => void,
54 };
@@ -51,11 +57,11 @@ export type Handler = (data: any) => void;
57
58 export type Hook = {
59 listeners: { [key: string]: Array<Handler> },
54 - rendererInterfaces: { [key: string]: RendererInterface },
55 - renderers: { [key: string]: ReactRenderer },
60 + rendererInterfaces: Map<RendererID, RendererInterface>,
61 + renderers: Map<RendererID, ReactRenderer>,
62
63 emit: (evt: string, data: any) => void,
58 - getFiberRoots: (rendererID: string) => Set<Object>,
64 + getFiberRoots: (rendererID: RendererID) => Set<Object>,
65 inject: (renderer: ReactRenderer) => string | null,
66 on: (evt: string, handler: Handler) => void,
67 off: (evt: string, handler: Handler) => void,
@@ -67,3 +73,16 @@ export type Hook = {
73 onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void,
74 onCommitFiberRoot: (rendererID: RendererID, fiber: Object) => void,
75 };
76 +
77 +export type HooksNode = {
78 + name: string,
79 + value: mixed,
80 + subHooks: Array<HooksNode>,
81 +};
82 +export type HooksTree = Array<HooksNode>;
83 +
84 +export type InspectedHooks = {|
85 + elementID: string,
86 + id: string,
87 + hooksTree: HooksTree,
88 +|};
src/backend/utils.js
+39 -7
@@ -1,5 +1,10 @@
1 // @flow
2
3 +import { isElement } from 'react-is';
4 +import { dehydrate } from '../hydration';
5 +
6 +import type { DehydratedData } from 'src/devtools/types';
7 +
8 const FB_MODULE_RE = /^(.*) \[from (.*)\]$/;
9 const cachedDisplayNames: WeakMap<Function, string> = new WeakMap();
10
@@ -45,11 +50,38 @@ export function getDisplayName(
50 return displayName;
51 }
52
48 -export function guid(): string {
49 - return (
50 - 'g' +
51 - Math.random()
52 - .toString(16)
53 - .substr(2)
54 - );
53 +export function cleanForBridge(data: Object | null): DehydratedData | null {
54 + if (data !== null) {
55 + const cleaned = [];
56 +
57 + return {
58 + data: dehydrate(data, cleaned),
59 + cleaned,
60 + };
61 + } else {
62 + return null;
63 + }
64 +}
65 +
66 +export function cleanPropsForBridge(props: Object): DehydratedData | null {
67 + if (props !== null) {
68 + const dehydratedData = cleanForBridge(props);
69 + if (dehydratedData !== null) {
70 + // For now, let's just filter children that are React elements.
71 + const { children } = props;
72 + if (children != null) {
73 + if (isElement(children)) {
74 + delete dehydratedData.data.children;
75 + } else if (Array.isArray(children)) {
76 + dehydratedData.data.children = children.filter(
77 + child => !isElement(child)
78 + );
79 + }
80 + }
81 +
82 + return dehydratedData;
83 + }
84 + }
85 +
86 + return null;
87 }
src/devtools/store.js
+72 -1
@@ -40,6 +40,9 @@ export default class Store extends EventEmitter {
40 // Passive effects will check it for changes between render and mount.
41 _roots: $ReadOnlyArray<number> = [];
42
43 + // Renderer ID is needed to support inspection fiber props, state, and hooks.
44 + _rootIDToRendererID: Map<number, number> = new Map();
45 +
46 constructor(bridge: Bridge) {
47 super();
48
@@ -117,6 +120,70 @@ export default class Store extends EventEmitter {
120 return element;
121 }
122
123 + getIndexOfElementID(id: number): number | null {
124 + const element = this.getElementByID(id);
125 +
126 + if (element === null) {
127 + return null;
128 + }
129 +
130 + // Walk up the tree to the root.
131 + // Increment the index by one for each node we encounter,
132 + // and by the weight of all nodes to the left of the current one.
133 + // This should be a relatively fast way of determining the index of a node within the tree.
134 + let previousID = id;
135 + let currentID = element.parentID;
136 + let index = 0;
137 + while (true) {
138 + const current = ((this._idToElement.get(currentID): any): Element);
139 + if (current.parentID === 0) {
140 + // We found the root; stop crawling.
141 + break;
142 + }
143 +
144 + index++;
145 +
146 + const { children } = current;
147 + for (let i = 0; i < children.length; i++) {
148 + const childID = children[i];
149 + if (childID === previousID) {
150 + break;
151 + }
152 + const child = ((this._idToElement.get(childID): any): Element);
153 + index += child.weight;
154 + }
155 +
156 + previousID = current.id;
157 + currentID = current.parentID;
158 + }
159 +
160 + // At this point, the current ID is a root (from the previous loop).
161 + // We also need to offset the index by previous root weights.
162 + for (let i = 0; i < this._roots.length; i++) {
163 + const rootID = this._roots[i];
164 + if (rootID === currentID) {
165 + break;
166 + }
167 + const root = ((this._idToElement.get(rootID): any): Element);
168 + index += root.weight;
169 + }
170 +
171 + return index;
172 + }
173 +
174 + getRendererIDForElement(id: number): number | null {
175 + let current = this._idToElement.get(id);
176 + while (current != null) {
177 + if (current.parentID === 0) {
178 + const rendererID = this._rootIDToRendererID.get(current.id);
179 + return rendererID == null ? null : ((rendererID: any): number);
180 + } else {
181 + current = this._idToElement.get(current.parentID);
182 + }
183 + }
184 + return null;
185 + }
186 +
187 onBridgeOperations = (operations: Uint32Array) => {
188 if (!(operations instanceof Uint32Array)) {
189 // $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 {
194
195 let haveRootsChanged = false;
196
130 - let i = 0;
197 + const rendererID = operations[0];
198 +
199 + let i = 1;
200 while (i < operations.length) {
201 let id: number = ((null: any): number);
202 let element: Element = ((null: any): Element);
@@ -150,6 +219,7 @@ export default class Store extends EventEmitter {
219 debug('Add', `new root fiber ${id}`);
220
221 this._roots = this._roots.concat(id);
222 + this._rootIDToRendererID.set(id, rendererID);
223
224 this._idToElement.set(id, {
225 children: [],
@@ -227,6 +297,7 @@ export default class Store extends EventEmitter {
297 parentElement = ((this._idToElement.get(parentID): any): Element);
298 if (parentElement == null) {
299 this._roots = this._roots.filter(rootID => rootID !== id);
300 + this._rootIDToRendererID.delete(id);
301 } else {
302 parentElement.children = parentElement.children.filter(
303 childID => childID !== id
src/devtools/types.js
+12 -2
@@ -38,6 +38,11 @@ export type Element = {|
38 weight: number,
39 |};
40
41 +export type Owner = {|
42 + displayName: string,
43 + id: number,
44 +|};
45 +
46 export type InspectedElement = {|
47 id: number,
48
@@ -51,10 +56,10 @@ export type InspectedElement = {|
56 state: Object | null,
57
58 // List of owners
54 - owners: Array<number>,
59 + owners: Array<Owner> | null,
60
61 // Location of component in source coude.
57 - source: Object,
62 + source: Object | null,
63 |};
64
65 // TODO: Add profiling type
@@ -66,3 +71,8 @@ export type TreeMetadataType = {|
71 size: number,
72 store: Store,
73 |};
74 +
75 +export type DehydratedData = {|
76 + cleaned: Array<Array<string>>,
77 + data: Object,
78 +|};
src/devtools/views/Element.css
+4 -4
@@ -6,6 +6,8 @@
6 position: relative;
7 white-space: nowrap;
8 line-height: 20px;
9 + display: flex;
10 + align-items: center;
11 }
12 .Element:hover {
13 background-color: var(--color-state03);
@@ -16,6 +18,7 @@
18
19 /* Invert colors */
20 --color-component: var(--color-base00);
21 + --color-arrow: var(--color-base00);
22 --color-tree-tag: rgba(255, 255, 255, 0.8);
23 --color-tree-attr-name: #b3e5fc;
24 --color-tree-attr-value: #fff;
@@ -27,10 +30,7 @@
30 margin-left: -1rem;
31 width: 1rem;
32 height: 1rem;
30 - 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");
31 - background-size: 12px 12px;
32 - background-repeat: no-repeat;
33 - background-position: center;
33 + color: var(--color-arrow);
34 }
35 .ArrowOpen {
36 transform: rotate(90deg);
src/devtools/views/Element.js
+9 -4
@@ -3,6 +3,7 @@
3 import React, { Fragment, useCallback, useContext } from 'react';
4 import { TreeContext } from './context';
5 import { SelectedElementContext } from './SelectedElementContext';
6 +import Icon from './Icon';
7
8 import styles from './Element.css';
9
@@ -23,6 +24,8 @@ export default function Element({ index, style }: Props) {
24 return null;
25 }
26
27 + // TODO Add click and key handlers for toggling element open/close state.
28 +
29 const { children, depth, displayName, id, key } = element;
30
31 const selectedElement = useContext(SelectedElementContext);
@@ -33,8 +36,6 @@ export default function Element({ index, style }: Props) {
36 [id]
37 );
38
36 - // TODO: Add click and key handlers for toggling element open/close state.
37 -
39 return (
40 <div
41 className={
@@ -42,11 +43,15 @@ export default function Element({ index, style }: Props) {
43 }
44 onClick={handleClick}
45 style={{
45 - ...style,
46 + ...style, // "style" comes from react-window
47 paddingLeft: `${1 + depth}rem`,
48 }}
49 >
49 - {children.length > 0 && <span className={styles.ArrowOpen} />}
50 + {children.length > 0 && (
51 + <span className={styles.ArrowOpen}>
52 + <Icon type="arrow" />
53 + </span>
54 + )}
55
56 <span className={styles.Component}>
57 {displayName}
src/devtools/views/Elements.css
-1
@@ -5,7 +5,6 @@
5 flex-direction: row;
6
7 font-family: var(--font-family-sans);
8 - -webkit-font-smoothing: antialiased;
8 }
9
10 .TreeWrapper {
src/devtools/views/Elements.js
+24 -16
@@ -1,9 +1,9 @@
1 // @flow
2
3 -import React, { useLayoutEffect, useMemo, useState } from 'react';
3 +import React, { useEffect, useMemo, useState } from 'react';
4 import Store from '../store';
5 import Tree from './Tree';
6 -import { StoreContext, TreeContext } from './context';
6 +import { BridgeContext, StoreContext, TreeContext } from './context';
7 import SelectedElement from './SelectedElement';
8 import { SelectedElementController } from './SelectedElementContext';
9 import styles from './Elements.css';
@@ -26,7 +26,7 @@ export default function Elements({ bridge, browserName, themeName }: Props) {
26 store,
27 });
28
29 - useLayoutEffect(() => {
29 + useEffect(() => {
30 const handler = () => {
31 setTreeContext({
32 size: store.numElements,
@@ -34,6 +34,12 @@ export default function Elements({ bridge, browserName, themeName }: Props) {
34 });
35 };
36
37 + // Check for changes that happened between async render and passive effect.
38 + // (We had not yet subscribed to the store so we would have missed these.)
39 + if (treeContext.size !== store.numElements) {
40 + handler();
41 + }
42 +
43 store.addListener('rootCommitted', handler);
44
45 return () => store.removeListener('rootCommitted', handler);
@@ -41,19 +47,21 @@ export default function Elements({ bridge, browserName, themeName }: Props) {
47
48 // TODO Flex wrappers below should be user resizable.
49 return (
44 - <StoreContext.Provider value={store}>
45 - <TreeContext.Provider value={treeContext}>
46 - <SelectedElementController>
47 - <div className={styles.Elements}>
48 - <div className={styles.TreeWrapper}>
49 - <Tree />
50 - </div>
51 - <div className={styles.SelectedElementWrapper}>
52 - <SelectedElement />
50 + <BridgeContext.Provider value={bridge}>
51 + <StoreContext.Provider value={store}>
52 + <TreeContext.Provider value={treeContext}>
53 + <SelectedElementController>
54 + <div className={styles.Elements}>
55 + <div className={styles.TreeWrapper}>
56 + <Tree />
57 + </div>
58 + <div className={styles.SelectedElementWrapper}>
59 + <SelectedElement />
60 + </div>
61 </div>
54 - </div>
55 - </SelectedElementController>
56 - </TreeContext.Provider>
57 - </StoreContext.Provider>
62 + </SelectedElementController>
63 + </TreeContext.Provider>
64 + </StoreContext.Provider>
65 + </BridgeContext.Provider>
66 );
67 }
src/devtools/views/HooksTree.css new
+10
@@ -0,0 +1,10 @@
1 +.HooksTree {
2 + padding: 0.25rem;
3 + border-bottom: 1px solid var(--color-base02);
4 +}
5 +
6 +.ComingSoon {
7 + padding-left: 1rem;
8 + color: var(--color-base03);
9 + font-style: italic;
10 +}
src/devtools/views/HooksTree.js new
+24
@@ -0,0 +1,24 @@
1 +// @flow
2 +
3 +import React from 'react';
4 +import styles from './HooksTree.css';
5 +
6 +import type { InspectedHooks } from 'src/backend/types';
7 +
8 +type Props = {|
9 + inspectedHooks: InspectedHooks | null,
10 +|};
11 +
12 +export default function HooksTree({ inspectedHooks }: Props) {
13 + if (inspectedHooks === null) {
14 + return null;
15 + }
16 +
17 + // TODO
18 + return (
19 + <div className={styles.HooksTree}>
20 + hooks
21 + <div className={styles.ComingSoon}>Coming soon...</div>
22 + </div>
23 + );
24 +}
src/devtools/views/Icon.css new
+5
@@ -0,0 +1,5 @@
1 +.Icon {
2 + width: 1rem;
3 + height: 1rem;
4 + fill: currentColor;
5 +}
src/devtools/views/Icon.js new
+35
@@ -0,0 +1,35 @@
1 +// @flow
2 +
3 +import React from 'react';
4 +import styles from './Icon.css';
5 +
6 +type Props = {|
7 + type: 'arrow',
8 +|};
9 +
10 +export default function Icon({ type }: Props) {
11 + let pathData = null;
12 + switch (type) {
13 + case 'arrow':
14 + pathData = PATH_ARROW;
15 + break;
16 + default:
17 + console.warn(`Unsupported type "${type}" specified for Icon`);
18 + break;
19 + }
20 +
21 + return (
22 + <svg
23 + xmlns="http://www.w3.org/2000/svg"
24 + className={styles.Icon}
25 + width="24"
26 + height="24"
27 + viewBox="0 0 24 24"
28 + >
29 + <path d="M0 0h24v24H0z" fill="none" />
30 + <path fill="currentColor" d={pathData} />
31 + </svg>
32 + );
33 +}
34 +
35 +const PATH_ARROW = 'M8 5v14l11-7z';
src/devtools/views/InspectedElementTree.css new
+20
@@ -0,0 +1,20 @@
1 +.InspectedElementTree {
2 + padding: 0.25rem;
3 + border-bottom: 1px solid var(--color-base02);
4 +}
5 +
6 +.Item {
7 +}
8 +
9 +.Name {
10 + color: var(--color-tree-attr-name);
11 +}
12 +
13 +.Value {
14 + color: var(--color-tree-attr-value);
15 +}
16 +
17 +.None {
18 + color: var(--color-base03);
19 + font-style: italic;
20 +}
src/devtools/views/InspectedElementTree.js new
+126
@@ -0,0 +1,126 @@
1 +// @flow
2 +
3 +import React from 'react';
4 +import { meta } from '../../hydration';
5 +import styles from './InspectedElementTree.css';
6 +
7 +type Props = {|
8 + label: string,
9 + data: Object | null,
10 +|};
11 +
12 +export default function InspectedElementTree({ data, label }: Props) {
13 + if (data === null || Object.keys(data).length === 0) {
14 + return null;
15 + } else {
16 + // TODO Add click and key handlers for toggling element open/close state.
17 + // TODO Support editable props
18 +
19 + return (
20 + <div className={styles.InspectedElementTree}>
21 + <div className={styles.Item}>{label}</div>
22 + {Object.keys(data).map(name => (
23 + <KeyValue key={name} depth={1} name={name} value={data[name]} />
24 + ))}
25 + </div>
26 + );
27 + }
28 +}
29 +
30 +type KeyValueProps = {|
31 + depth: number,
32 + name: string,
33 + value: any,
34 +|};
35 +
36 +function KeyValue({ depth, name, value }: KeyValueProps) {
37 + const dataType = typeof value;
38 + const isSimpleType =
39 + dataType === 'number' ||
40 + dataType === 'string' ||
41 + dataType === 'boolean' ||
42 + value == null;
43 +
44 + let children = null;
45 + if (isSimpleType) {
46 + children = (
47 + <div
48 + key="root"
49 + className={styles.Item}
50 + style={{ paddingLeft: `${depth}rem` }}
51 + >
52 + <span className={styles.Name}>{name}</span>:{' '}
53 + <span className={styles.Value}>
54 + {dataType === 'string' ? `"${value}"` : value}
55 + </span>
56 + </div>
57 + );
58 + } else if (value.hasOwnProperty(meta.type)) {
59 + children = (
60 + <div
61 + key="root"
62 + className={styles.Item}
63 + style={{ paddingLeft: `${depth}rem` }}
64 + >
65 + <span className={styles.Name}>{name}</span>:{' '}
66 + <span className={styles.Value}>{getMetaValueLabel(value)}</span>
67 + </div>
68 + );
69 + } else {
70 + if (Array.isArray(value)) {
71 + children = value.map((innerValue, index) => (
72 + <KeyValue
73 + key={index}
74 + depth={depth + 1}
75 + name={index}
76 + value={value[index]}
77 + />
78 + ));
79 + children.unshift(
80 + <div
81 + key="root"
82 + className={styles.Item}
83 + style={{ paddingLeft: `${depth}rem` }}
84 + >
85 + <span className={styles.Name}>{name}</span>: <span>Array</span>
86 + </div>
87 + );
88 + } else {
89 + children = Object.entries(value).map(([name, value]) => (
90 + <KeyValue key={name} depth={depth + 1} name={name} value={value} />
91 + ));
92 + children.unshift(
93 + <div
94 + key="root"
95 + className={styles.Item}
96 + style={{ paddingLeft: `${depth}rem` }}
97 + >
98 + <span className={styles.Name}>{name}</span>: <span>Object</span>
99 + </div>
100 + );
101 + }
102 + }
103 +
104 + return children;
105 +}
106 +
107 +function getMetaValueLabel(data: Object): string | null {
108 + switch (data[meta.type]) {
109 + case 'function':
110 + return `${data[meta.name] || 'fn'}()`;
111 + case 'object':
112 + return 'Object';
113 + case 'date':
114 + case 'symbol':
115 + return data[meta.name];
116 + case 'iterator':
117 + return `${data[meta.name]}(…)`;
118 + case 'array_buffer':
119 + case 'data_view':
120 + case 'array':
121 + case 'typed_array':
122 + return `${data[meta.name]}[${data[meta.meta].length}]`;
123 + default:
124 + return null;
125 + }
126 +}
src/devtools/views/SelectedElement.css
+32 -4
@@ -36,19 +36,47 @@
36 text-overflow: ellipsis;
37 }
38
39 -.Component {
39 +.Owners {
40 + padding: 0.25rem;
41 +}
42 +
43 +.Component,
44 +.Owner {
45 overflow: hidden;
46 text-overflow: ellipsis;
47 color: var(--color-component);
43 - flex: 1 1 auto;
48 }
45 -.Component:before {
49 +.Component:before,
50 +.Owner:before {
51 white-space: nowrap;
52 content: '<';
53 color: var(--color-tree-tag);
54 }
50 -.Component:after {
55 +.Component:after,
56 +.Owner:after {
57 white-space: nowrap;
58 content: '>';
59 color: var(--color-tree-tag);
60 }
61 +
62 +.Component {
63 + flex: 1 1 auto;
64 +}
65 +
66 +.InspectedElement {
67 + overflow: auto;
68 + font-family: var(--font-family-monospace);
69 + font-size: var(--font-size-tree-compact);
70 + line-height: var(--line-height-compact);
71 +}
72 +
73 +.Owner {
74 + padding-left: 1rem;
75 + cursor: pointer;
76 +}
77 +
78 +.Loading {
79 + padding: 0.25rem;
80 + color: var(--color-base03);
81 + font-style: italic;
82 +}
src/devtools/views/SelectedElement.js
+144 -12
@@ -1,19 +1,29 @@
1 // @flow
2
3 -import React, { useContext } from 'react';
3 +import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
4 import { SelectedElementContext } from './SelectedElementContext';
5 -import { StoreContext } from './context';
5 +import { BridgeContext, StoreContext } from './context';
6 import ButtonIcon from './ButtonIcon';
7 +import HooksTree from './HooksTree';
8 +import InspectedElementTree from './InspectedElementTree';
9 +import { hydrate } from 'src/hydration';
10 import styles from './SelectedElement.css';
11
12 +import type { InspectedElement } from '../types';
13 +import type { DehydratedData } from 'src/devtools/types';
14 +import type { SelectedElementContextValue } from './SelectedElementContext';
15 +
16 export type Props = {||};
17
11 -export default function SelectedElement(props: Props) {
12 - const { id } = useContext(SelectedElementContext);
18 +export default function SelectedElement(_: Props) {
19 + const selectedElement = useContext(SelectedElementContext);
20 + const { id } = selectedElement;
21 const store = useContext(StoreContext);
22 const element = id !== null ? store.getElementByID(id) : null;
23
16 - // TODO Show/hide source button based on whether debug "source" is available.
24 + const inspectedElement = useInspectedElement(id);
25 +
26 + // TODO Make "view DOM" and "view source" buttons work
27
28 if (element === null) {
29 return (
@@ -23,6 +33,8 @@ export default function SelectedElement(props: Props) {
33 );
34 }
35
36 + const source = inspectedElement ? inspectedElement.source : null;
37 +
38 return (
39 <div className={styles.SelectedElement}>
40 <div className={styles.TitleRow}>
@@ -36,15 +48,135 @@ export default function SelectedElement(props: Props) {
48 className={styles.IconButton}
49 title="Highlight this element in the page"
50 >
39 - <ButtonIcon type="view-dom" /> DOM
40 - </button>
41 - <button
42 - className={styles.IconButton}
43 - title="View source for this element"
44 - >
45 - <ButtonIcon type="view-source" /> Source
51 + <ButtonIcon type="view-dom" />
52 </button>
53 + {source !== null && (
54 + <button
55 + className={styles.IconButton}
56 + title="View source for this element"
57 + >
58 + <ButtonIcon type="view-source" />
59 + </button>
60 + )}
61 </div>
62 +
63 + {inspectedElement === null && (
64 + <div className={styles.Loading}>Loading...</div>
65 + )}
66 +
67 + {inspectedElement !== null && (
68 + <InspectedElementView
69 + inspectedElement={inspectedElement}
70 + selectedElement={selectedElement}
71 + />
72 + )}
73 + </div>
74 + );
75 +}
76 +
77 +type InspectedElementViewProps = {|
78 + inspectedElement: InspectedElement,
79 + selectedElement: SelectedElementContextValue,
80 +|};
81 +
82 +function InspectedElementView({
83 + inspectedElement,
84 + selectedElement,
85 +}: InspectedElementViewProps) {
86 + let { context, hooks, owners, props, state } = inspectedElement;
87 +
88 + return (
89 + <div className={styles.InspectedElement}>
90 + <InspectedElementTree label="props" data={props} />
91 + <InspectedElementTree label="state" data={state} />
92 + <HooksTree inspectedHooks={hooks} />
93 + <InspectedElementTree label="context" data={context} />
94 +
95 + {owners !== null && owners.length > 0 && (
96 + <div className={styles.Owners}>
97 + <div>owners</div>
98 + {owners.map(owner => (
99 + <div
100 + key={owner.id}
101 + className={styles.Owner}
102 + onClick={() => {
103 + selectedElement.id = owner.id;
104 + }}
105 + title={owner.displayName}
106 + >
107 + {owner.displayName}
108 + </div>
109 + ))}
110 + </div>
111 + )}
112 </div>
113 );
114 }
115 +
116 +function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
117 + if (dehydratedData !== null) {
118 + return hydrate(dehydratedData.data, dehydratedData.cleaned);
119 + } else {
120 + return null;
121 + }
122 +}
123 +
124 +function useInspectedElement(id: number | null): InspectedElement | null {
125 + const idRef = useRef(id);
126 + const bridge = useContext(BridgeContext);
127 + const store = useContext(StoreContext);
128 +
129 + const rendererID = id === null ? null : store.getRendererIDForElement(id);
130 +
131 + const [inspectedElement, setInspectedElement] = useState(null);
132 +
133 + // Track the most recently-requested element.
134 + // We'll ignore any backend updates about previous elements.
135 + idRef.current = id;
136 +
137 + useLayoutEffect(() => {
138 + if (id === null) {
139 + return () => {};
140 + }
141 +
142 + // Hide previous/stale insepected element to avoid temporarily showing the wrong values.
143 + setInspectedElement(null);
144 +
145 + let timeoutID = null;
146 +
147 + const sendBridgeRequest = () => {
148 + bridge.send('inspectElement', { id, rendererID });
149 + };
150 +
151 + const onInspectedElement = (inspectedElement: InspectedElement) => {
152 + if (idRef.current !== inspectedElement.id) {
153 + // Ignore bridge updates about previously selected elements.
154 + return;
155 + }
156 +
157 + inspectedElement.context = hydrateHelper(inspectedElement.context);
158 + inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
159 + inspectedElement.props = hydrateHelper(inspectedElement.props);
160 + inspectedElement.state = hydrateHelper(inspectedElement.state);
161 +
162 + setInspectedElement(inspectedElement);
163 +
164 + // Ask for an update in a second...
165 + timeoutID = setTimeout(sendBridgeRequest, 1000);
166 + };
167 +
168 + bridge.addListener('inspectedElement', onInspectedElement);
169 +
170 + sendBridgeRequest();
171 +
172 + return () => {
173 + if (timeoutID !== null) {
174 + clearTimeout(timeoutID);
175 + }
176 +
177 + bridge.removeListener('inspectedElement', onInspectedElement);
178 + };
179 + }, [id]);
180 +
181 + return inspectedElement;
182 +}
src/devtools/views/SelectedElementContext.js
+17 -2
@@ -1,9 +1,11 @@
1 // @flow
2
3 -import React, { createContext, useMemo, useState } from 'react';
3 +import React, { createContext, useContext, useMemo, useState } from 'react';
4 +import { StoreContext } from './context';
5
5 -type SelectedElementContextValue = {|
6 +export type SelectedElementContextValue = {|
7 id: number | null,
8 + index: number | null,
9 |};
10
11 const SelectedElementContext = createContext<SelectedElementContextValue>(
@@ -18,7 +20,11 @@ type Props = {|
20
21 // TODO Remove this wrapper element once global Context.write API exists.
22 function SelectedElementController({ children }: Props) {
23 + const store = useContext(StoreContext);
24 +
25 const [id, setID] = useState<number | null>(null);
26 + const [index, setIndex] = useState<number | null>(null);
27 +
28 const value = useMemo(
29 () => ({
30 get id() {
@@ -26,6 +32,15 @@ function SelectedElementController({ children }: Props) {
32 },
33 set id(id: number | null) {
34 setID(id);
35 + setIndex(id !== null ? store.getIndexOfElementID(id) : null);
36 + },
37 + get index() {
38 + return index;
39 + },
40 + set index(index: number | null) {
41 + const element = index !== null ? store.getElementAtIndex(index) : null;
42 + setID(element !== null ? element.id : null);
43 + setIndex(index);
44 },
45 }),
46 [id]
src/devtools/views/Tree.js
+45 -3
@@ -1,8 +1,9 @@
1 // @flow
2
3 -import React, { useContext } from 'react';
3 +import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
4 import AutoSizer from 'react-virtualized-auto-sizer';
5 import { FixedSizeList } from 'react-window';
6 +import { SelectedElementContext } from './SelectedElementContext';
7 import Element from './Element';
8 import ButtonIcon from './ButtonIcon';
9 import { TreeContext } from './context';
@@ -12,9 +13,49 @@ import styles from './Tree.css';
13 type Props = {||};
14
15 export default function Tree(props: Props) {
16 + const selectedElementContext = useContext(SelectedElementContext);
17 const treeContext = useContext(TreeContext);
18 + const listRef = useRef();
19
17 - // TODO Add key handlers for selecting previous/next element.
20 + // Make sure a newly selected element is visible in the list.
21 + // This is helpful for things like the owners list.
22 + useLayoutEffect(() => {
23 + const { index } = selectedElementContext;
24 + if (index !== null && listRef.current != null) {
25 + listRef.current.scrollToItem(index);
26 + }
27 + }, [listRef, selectedElementContext]);
28 +
29 + // Navigate the tree with up/down arrow keys.
30 + useEffect(() => {
31 + const handleKeyDown = event => {
32 + let index;
33 +
34 + // eslint-disable-next-line default-case
35 + switch (event.key) {
36 + case 'ArrowDown':
37 + index = selectedElementContext.index;
38 + if (index !== null && index + 1 < treeContext.size) {
39 + selectedElementContext.index = ((index: any): number) + 1;
40 + }
41 + event.preventDefault();
42 + break;
43 + case 'ArrowUp':
44 + index = selectedElementContext.index;
45 + if (index !== null && index > 0) {
46 + selectedElementContext.index = ((index: any): number) - 1;
47 + }
48 + event.preventDefault();
49 + break;
50 + }
51 + };
52 +
53 + window.addEventListener('keydown', handleKeyDown);
54 +
55 + return () => {
56 + window.removeEventListener('keydown', handleKeyDown);
57 + };
58 + }, [selectedElementContext, treeContext]);
59
60 return (
61 <div className={styles.Tree}>
@@ -27,7 +68,7 @@ export default function Tree(props: Props) {
68 className={styles.IconButton}
69 title="Select an element in the page to inspect it"
70 >
30 - <ButtonIcon type="search" /> Select
71 + <ButtonIcon type="search" />
72 </button>
73 </div>
74 <div className={styles.AutoSizerWrapper}>
@@ -39,6 +80,7 @@ export default function Tree(props: Props) {
80 itemCount={treeContext.size}
81 itemData={treeContext}
82 itemSize={20}
83 + ref={listRef}
84 width={width}
85 >
86 {Element}
src/devtools/views/context.js
+5
@@ -2,10 +2,15 @@
2
3 import { createContext } from 'react';
4
5 +import type { Bridge } from '../../types';
6 import type { TreeMetadataType } from '../types';
7
8 import Store from '../store';
9
10 +export const BridgeContext = createContext<Bridge>(((null: any): Bridge));
11 +// $FlowFixMe displayName is a valid attribute of React$Context
12 +BridgeContext.displayName = 'BridgeContext';
13 +
14 export const StoreContext = createContext<Store>(((null: any): Store));
15 // $FlowFixMe displayName is a valid attribute of React$Context
16 StoreContext.displayName = 'StoreContext';
src/devtools/views/root.css
+6 -1
@@ -3,7 +3,7 @@
3 --color-base00: #ffffff;
4 --color-base01: #f3f3f3;
5 --color-base02: #eeeeee;
6 - --color-base03: #dadada;
6 + --color-base03: #cfd1d5;
7 --color-base04: #333333;
8 --color-base05: #5a5a5a;
9 --color-special00: #8155cb;
@@ -22,6 +22,7 @@
22 --color-state05: #222222;
23 --color-state06: #222222;
24
25 + --color-arrow: var(--color-special07);
26 --color-component: var(--color-special00);
27 --color-tree-tag: var(--color-base04);
28 --color-tree-attr-name: var(--color-special06);
@@ -37,8 +38,12 @@
38 Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
39 --font-size-tree-compact: 12px;
40 --font-size-tree-normal: 14px;
41 + --line-height-compact: 20px;
42 + --line-height-normal: 24px;
43 }
44
45 * {
46 box-sizing: border-box;
47 +
48 + -webkit-font-smoothing: antialiased;
49 }
src/hook.js
+12 -10
@@ -67,11 +67,11 @@ export function installHook(target: any): Hook {
67 } catch (err) {}
68 }
69
70 + let uidCounter = 0;
71 +
72 function inject(renderer) {
71 - const id = Math.random()
72 - .toString(16)
73 - .slice(2);
74 - renderers[id] = renderer;
73 + const id = ++uidCounter;
74 + renderers.set(id, renderer);
75
76 const reactBuildType = hasDetectedBadDCE
77 ? 'deadcode'
@@ -125,8 +125,9 @@ export function installHook(target: any): Hook {
125
126 function onCommitFiberUnmount(rendererID, fiber) {
127 // TODO: can we use hook for roots too?
128 - if (rendererInterfaces[rendererID]) {
129 - rendererInterfaces[rendererID].handleCommitFiberUnmount(fiber);
128 + const rendererInterface = rendererInterfaces.get(rendererID);
129 + if (rendererInterface != null) {
130 + rendererInterface.handleCommitFiberUnmount(fiber);
131 }
132 }
133
@@ -143,16 +144,17 @@ export function installHook(target: any): Hook {
144 } else if (isKnownRoot && isUnmounting) {
145 mountedRoots.delete(root);
146 }
146 - if (rendererInterfaces[rendererID]) {
147 - rendererInterfaces[rendererID].handleCommitFiberRoot(root);
147 + const rendererInterface = rendererInterfaces.get(rendererID);
148 + if (rendererInterface != null) {
149 + rendererInterface.handleCommitFiberRoot(root);
150 }
151 }
152
153 // TODO: More meaningful names for "rendererInterfaces" and "renderers".
154 const fiberRoots = {};
153 - const rendererInterfaces = {};
155 + const rendererInterfaces = new Map();
156 const listeners = {};
155 - const renderers = {};
157 + const renderers = new Map();
158
159 const hook: Hook = {
160 rendererInterfaces,
src/hydration.js new
+216
@@ -0,0 +1,216 @@
1 +// @flow
2 +
3 +export const meta = {
4 + name: Symbol('name'),
5 + type: Symbol('type'),
6 + inspected: Symbol('inspected'),
7 + meta: Symbol('meta'),
8 + proto: Symbol('proto'),
9 +};
10 +
11 +// This threshold determines the depth at which the bridge "dehydrates" nested data.
12 +// Dehydration means that we don't serialize the data for e.g. postMessage or stringify,
13 +// unless the frontend explicitly requests it (e.g. a user clicks to expand a props object).
14 +// We tried reducing this value from 2 to 1 to improve performance:
15 +// https://github.com/facebook/react-devtools/issues/1200
16 +// But this caused problems with the Profiler's interaction tracing output.
17 +// Because React mutates Fibers, profiling data that is dehydrated for old commits–
18 +// will not be available later from within the Profiler.
19 +// This impacts props/state as well as Interactions.
20 +// https://github.com/facebook/react-devtools/issues/1262
21 +const LEVEL_THRESHOLD = 6;
22 +
23 +/**
24 + * Get a enhanced/artificial type string based on the object instance
25 + */
26 +function getPropType(data: Object): string | null {
27 + if (!data) {
28 + return null;
29 + }
30 + const type = typeof data;
31 +
32 + if (type === 'object') {
33 + if (data._reactFragment) {
34 + return 'react_fragment';
35 + }
36 + if (Array.isArray(data)) {
37 + return 'array';
38 + }
39 + if (ArrayBuffer.isView(data)) {
40 + if (data instanceof DataView) {
41 + return 'data_view';
42 + }
43 + return 'typed_array';
44 + }
45 + if (data instanceof ArrayBuffer) {
46 + return 'array_buffer';
47 + }
48 + if (typeof data[Symbol.iterator] === 'function') {
49 + return 'iterator';
50 + }
51 + if (Object.prototype.toString.call(data) === '[object Date]') {
52 + return 'date';
53 + }
54 + }
55 +
56 + return type;
57 +}
58 +
59 +/**
60 + * Generate the dehydrated metadata for complex object instances
61 + */
62 +function createDehydrated(
63 + type: string,
64 + data: Object,
65 + cleaned: Array<Array<string>>,
66 + path: Array<string>
67 +): Object {
68 + const meta = {};
69 +
70 + if (type === 'array' || type === 'typed_array') {
71 + meta.length = data.length;
72 + }
73 + if (type === 'iterator' || type === 'typed_array') {
74 + meta.readOnly = true;
75 + }
76 +
77 + cleaned.push(path);
78 +
79 + return {
80 + type,
81 + meta,
82 + name:
83 + !data.constructor || data.constructor.name === 'Object'
84 + ? ''
85 + : data.constructor.name,
86 + };
87 +}
88 +
89 +/**
90 + * Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep).
91 + * The paths of the stripped out objects are appended to the `cleaned` list.
92 + * On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into
93 + * an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object.
94 + *
95 + * Input: {"some": {"attr": fn()}, "other": AnInstance}
96 + * Output: {
97 + * "some": {
98 + * "attr": {"name": the fn.name, type: "function"}
99 + * },
100 + * "other": {
101 + * "name": "AnInstance",
102 + * "type": "object",
103 + * },
104 + * }
105 + * and cleaned = [["some", "attr"], ["other"]]
106 + */
107 +export function dehydrate(
108 + data: Object,
109 + cleaned: Array<Array<string>>,
110 + path?: Array<string> = [],
111 + level?: number = 0
112 +): string | Object {
113 + const type = getPropType(data);
114 +
115 + switch (type) {
116 + case 'function':
117 + cleaned.push(path);
118 + return {
119 + name: data.name,
120 + type: 'function',
121 + };
122 +
123 + case 'string':
124 + return data.length <= 500 ? data : data.slice(0, 500) + '...';
125 +
126 + // We have to do this assignment b/c Flow doesn't think "symbol" is
127 + // something typeof would return. Error 'unexpected predicate "symbol"'
128 + case 'symbol':
129 + cleaned.push(path);
130 + return {
131 + type: 'symbol',
132 + name: data.toString(),
133 + };
134 +
135 + // React Fragments error if you try to inspect them.
136 + case 'react_fragment':
137 + return 'A React Fragment';
138 +
139 + // ArrayBuffers error if you try to inspect them.
140 + case 'array_buffer':
141 + case 'data_view':
142 + cleaned.push(path);
143 + return {
144 + type,
145 + name: type === 'data_view' ? 'DataView' : 'ArrayBuffer',
146 + meta: {
147 + length: data.byteLength,
148 + uninspectable: true,
149 + },
150 + };
151 +
152 + case 'array':
153 + if (level > LEVEL_THRESHOLD) {
154 + return createDehydrated(type, data, cleaned, path);
155 + }
156 + return data.map((item, i) =>
157 + dehydrate(item, cleaned, path.concat([i]), level + 1)
158 + );
159 +
160 + case 'typed_array':
161 + case 'iterator':
162 + return createDehydrated(type, data, cleaned, path);
163 + case 'date':
164 + cleaned.push(path);
165 + return {
166 + name: data.toString(),
167 + type: 'date',
168 + meta: {
169 + uninspectable: true,
170 + },
171 + };
172 + case 'object':
173 + if (
174 + level > LEVEL_THRESHOLD ||
175 + (data.constructor &&
176 + typeof data.constructor === 'function' &&
177 + data.constructor.name !== 'Object')
178 + ) {
179 + return createDehydrated(type, data, cleaned, path);
180 + } else {
181 + const res = {};
182 + for (let name in data) {
183 + res[name] = dehydrate(
184 + data[name],
185 + cleaned,
186 + path.concat([name]),
187 + level + 1
188 + );
189 + }
190 + return res;
191 + }
192 +
193 + default:
194 + return data;
195 + }
196 +}
197 +
198 +export function hydrate(data: Object, cleaned: Array<Array<string>>): Object {
199 + cleaned.forEach((path: Array<string>) => {
200 + const last = path.pop();
201 + const reduced: Object = path.reduce(
202 + (object: Object, attr: string) => (object ? object[attr] : (null: any)),
203 + data
204 + );
205 + if (!reduced || !reduced[last]) {
206 + return;
207 + }
208 + const replace: { [key: Symbol]: boolean | string } = {};
209 + replace[meta.name] = reduced[last].name;
210 + replace[meta.type] = reduced[last].type;
211 + replace[meta.meta] = reduced[last].meta;
212 + replace[meta.inspected] = false;
213 + reduced[last] = replace;
214 + });
215 + return data;
216 +}
src/utils.js
+6
@@ -1,5 +1,11 @@
1 // @flow
2
3 +let uidCounter: number = 0;
4 +
5 +export function getUID(): number {
6 + return ++uidCounter;
7 +}
8 +
9 export function utfDecodeString(array: Uint8Array): string {
10 let string = '';
11 const { length } = array;
yarn.lock
+5
@@ -8411,6 +8411,11 @@ react-dom@^16.8.0-alpha.1:
8411 prop-types "^15.6.2"
8412 scheduler "^0.13.0-alpha.1"
8413
8414 +react-is@^16.8.0-alpha.1:
8415 + version "16.8.0-alpha.1"
8416 + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.0-alpha.1.tgz#ac1aed207d6040f002b645af36702edf9ce2c40d"
8417 + integrity sha512-Gsh2u4ovhS2DY6fWgie/av5vzrIfW6P0lgWAsAQp9DjOImE0fJ26FfEdpFXtYBwi5s2krT9z0xvcQKvQsi4ekw==
8418 +
8419 react-portal@^3.1.0:
8420 version "3.1.0"
8421 resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899"