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"