Naive suspense implementation for selected element panel
This commit leaves a few major things uunresolved: * We aren't yet polling for updates * We aren't yet using the two setState pattern * The resource cache will grow unbounded over time because we aren't yet clearing items from it * The renderer interface is not smart enough to avoid resending unchanged data between requests
Brian Vaughn committed
Apr 19, 2019 at 14:54 UTC
09b023a6e6f0d9a3eed95d488d6e910f305e4ad8
9 files changed
+175
-112
src/backend/renderer.js
+1
@@ -1492,6 +1492,7 @@ export function attach(
1492
}
1493
}
1494
1495
+ // TODO Send a no-op message if the specified Fiber hasn't been committed since it was last inspected.
1496
function inspectElementRaw(id: number): InspectedElement | null {
1497
let fiber = idToFiberMap.get(id);
1498
src/devtools/InspectedElementCache.js
new
+93
@@ -0,0 +1,93 @@
1
+// @flow
2
+
3
+import EventEmitter from 'events';
4
+import { createResource } from './cache';
5
+import Store from './store';
6
+import { hydrate } from 'src/hydration';
7
+
8
+import type {
9
+ DehydratedData,
10
+ InspectedElement,
11
+} from 'src/devtools/views/Components/types';
12
+import type { Resource } from './cache';
13
+import type { Bridge } from '../types';
14
+
15
+type ResolveFn = (inspectedElement: InspectedElement) => void;
16
+
17
+type Params = {|
18
+ id: number,
19
+ rendererID: number,
20
+|};
21
+
22
+// TODO Use an LRU for the underlying caching mechanism, to prevent memory leaks.
23
+
24
+// TODO Something needs to poll for (unprompted) updates.
25
+
26
+export default class InspectedElementCache extends EventEmitter {
27
+ _bridge: Bridge;
28
+ _store: Store;
29
+
30
+ _pendingRequests: Map<number, ResolveFn> = new Map();
31
+
32
+ _resource: Resource<Params, number, InspectedElement> = createResource(
33
+ ({ id, rendererID }: Params) => {
34
+ return new Promise(resolve => {
35
+ this._pendingRequests.set(id, resolve);
36
+ this._bridge.send('inspectElement', {
37
+ id,
38
+ rendererID,
39
+ });
40
+ });
41
+ },
42
+ ({ id, rendererID }: Params) => id
43
+ );
44
+
45
+ constructor(bridge: Bridge, store: Store) {
46
+ super();
47
+
48
+ this._bridge = bridge;
49
+ this._store = store;
50
+
51
+ bridge.addListener('inspectedElement', this._onInspectedElement);
52
+ }
53
+
54
+ read(id: number): InspectedElement | null {
55
+ const rendererID = this._store.getRendererIDForElement(id);
56
+
57
+ if (rendererID != null) {
58
+ return this._resource.read({ id, rendererID });
59
+ } else {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ _onInspectedElement = (inspectedElement: InspectedElement) => {
65
+ const id = inspectedElement.id;
66
+
67
+ if (inspectedElement != null) {
68
+ inspectedElement.context = hydrateHelper(inspectedElement.context);
69
+ inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
70
+ inspectedElement.props = hydrateHelper(inspectedElement.props);
71
+ inspectedElement.state = hydrateHelper(inspectedElement.state);
72
+ }
73
+
74
+ const resolveFn = this._pendingRequests.get(id);
75
+ if (resolveFn != null) {
76
+ this._pendingRequests.delete(id);
77
+
78
+ resolveFn(inspectedElement);
79
+ } else {
80
+ this._resource.write(id, inspectedElement);
81
+
82
+ this.emit('invalidated', id);
83
+ }
84
+ };
85
+}
86
+
87
+function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
88
+ if (dehydratedData !== null) {
89
+ return hydrate(dehydratedData.data, dehydratedData.cleaned);
90
+ } else {
91
+ return null;
92
+ }
93
+}
src/devtools/ProfilingCache.js
+4
@@ -92,6 +92,7 @@ export default class ProfilingCache {
92
93
CommitDetails: Resource<
94
CommitDetailsParams,
95
+ string,
96
CommitDetailsFrontend
97
> = createResource(
98
({ commitIndex, rendererID, rootID }: CommitDetailsParams) => {
@@ -136,6 +137,7 @@ export default class ProfilingCache {
137
138
FiberCommits: Resource<
139
FiberCommitsParams,
140
+ string,
141
FiberCommitsFrontend
142
> = createResource(
143
({ fiberID, rendererID, rootID }: FiberCommitsParams) => {
@@ -168,6 +170,7 @@ export default class ProfilingCache {
170
171
Interactions: Resource<
172
InteractionsParams,
173
+ number,
174
InteractionsFrontend
175
> = createResource(
176
({ rendererID, rootID }: InteractionsParams) => {
@@ -198,6 +201,7 @@ export default class ProfilingCache {
201
202
ProfilingSummary: Resource<
203
ProfilingSummaryParams,
204
+ number,
205
ProfilingSummaryFrontend
206
> = createResource(
207
({ rendererID, rootID }: ProfilingSummaryParams) => {
src/devtools/cache.js
+39
-21
@@ -25,9 +25,9 @@ type PendingResult = {|
25
value: Suspender,
26
|};
27
28
-type ResolvedResult<V> = {|
28
+type ResolvedResult<Value> = {|
29
status: 1,
30
- value: V,
30
+ value: Value,
31
|};
32
33
type RejectedResult = {|
@@ -35,11 +35,13 @@ type RejectedResult = {|
35
value: mixed,
36
|};
37
38
-type Result<V> = PendingResult | ResolvedResult<V> | RejectedResult;
38
+type Result<Value> = PendingResult | ResolvedResult<Value> | RejectedResult;
39
40
-export type Resource<I, V> = {
41
- read(I): V,
42
- preload(I): void,
40
+export type Resource<Input, Key, Value> = {
41
+ invalidate(Key): void,
42
+ read(Input): Value,
43
+ preload(Input): void,
44
+ write(Key, Value): void,
45
};
46
47
const Pending = 0;
@@ -67,14 +69,14 @@ function identityHashFn(input) {
69
70
const CacheContext = createContext(null);
71
70
-const entries: Map<Resource<any, any>, Map<any, any>> = new Map();
72
+const entries: Map<Resource<any, any, any>, Map<any, any>> = new Map();
73
72
-function accessResult<I, K, V>(
74
+function accessResult<Input, Key, Value>(
75
resource: any,
74
- fetch: I => Thenable<V>,
75
- input: I,
76
- key: K
77
-): Result<V> {
76
+ fetch: Input => Thenable<Value>,
77
+ input: Input,
78
+ key: Key
79
+): Result<Value> {
80
let entriesForResource = entries.get(resource);
81
if (entriesForResource === undefined) {
82
entriesForResource = new Map();
@@ -86,7 +88,7 @@ function accessResult<I, K, V>(
88
thenable.then(
89
value => {
90
if (newResult.status === Pending) {
89
- const resolvedResult: ResolvedResult<V> = (newResult: any);
91
+ const resolvedResult: ResolvedResult<Value> = (newResult: any);
92
resolvedResult.status = Resolved;
93
resolvedResult.value = value;
94
}
@@ -110,21 +112,28 @@ function accessResult<I, K, V>(
112
}
113
}
114
113
-export function createResource<I, K: string | number, V>(
114
- fetch: I => Thenable<V>,
115
- maybeHashInput?: I => K
116
-): Resource<I, V> {
117
- const hashInput: I => K =
115
+export function createResource<Input, Key: string | number, Value>(
116
+ fetch: Input => Thenable<Value>,
117
+ maybeHashInput?: Input => Key
118
+): Resource<Input, Key, Value> {
119
+ const hashInput: Input => Key =
120
maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
121
122
const resource = {
121
- read(input: I): V {
123
+ invalidate(key: Key): void {
124
+ const entriesForResource = entries.get(resource);
125
+ if (entriesForResource !== undefined) {
126
+ entriesForResource.delete(key);
127
+ }
128
+ },
129
+
130
+ read(input: Input): Value {
131
// Prevent access outside of render.
132
// eslint-disable-next-line react-hooks/rules-of-hooks
133
readContext(CacheContext);
134
135
const key = hashInput(input);
127
- const result: Result<V> = accessResult(resource, fetch, input, key);
136
+ const result: Result<Value> = accessResult(resource, fetch, input, key);
137
switch (result.status) {
138
case Pending: {
139
const suspender = result.value;
@@ -144,7 +153,7 @@ export function createResource<I, K: string | number, V>(
153
}
154
},
155
147
- preload(input: I): void {
156
+ preload(input: Input): void {
157
// Prevent access outside of render.
158
// eslint-disable-next-line react-hooks/rules-of-hooks
159
readContext(CacheContext);
@@ -152,6 +161,15 @@ export function createResource<I, K: string | number, V>(
161
const key = hashInput(input);
162
accessResult(resource, fetch, input, key);
163
},
164
+
165
+ write(key: Key, value: Value): void {
166
+ let entriesForResource = entries.get(resource);
167
+ if (entriesForResource === undefined) {
168
+ entriesForResource = new Map();
169
+ entries.set(resource, entriesForResource);
170
+ }
171
+ entriesForResource.set(key, value);
172
+ },
173
};
174
return resource;
175
}
src/devtools/store.js
+10
-1
@@ -13,6 +13,7 @@ import { ElementTypeRoot } from './types';
13
import { utfDecodeString } from '../utils';
14
import { __DEBUG__ } from '../constants';
15
import ProfilingCache from './ProfilingCache';
16
+import InspectedElementCache from './InspectedElementCache';
17
18
import type { ElementType } from './types';
19
import type { Element } from './views/Components/types';
@@ -75,11 +76,14 @@ export default class Store extends EventEmitter {
76
// The user has imported a previously exported profiling session.
77
_importedProfilingData: ImportedProfilingData | null = null;
78
79
+ // Suspense cache for lazy-loaded inspected Element data.
80
+ _inspectedElementCache: InspectedElementCache;
81
+
82
// The backend is currently profiling.
83
// When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
84
_isProfiling: boolean = false;
85
82
- // Suspense cache for reading profilign data.
86
+ // Suspense cache for reading profiling data.
87
_profilingCache: ProfilingCache;
88
89
// Map of root (id) to a list of tree mutation that occur during profiling.
@@ -170,6 +174,7 @@ export default class Store extends EventEmitter {
174
// so the frontend needs to ask the backend for its status after mounting.
175
bridge.send('getProfilingStatus');
176
177
+ this._inspectedElementCache = new InspectedElementCache(bridge, this);
178
this._profilingCache = new ProfilingCache(bridge, this);
179
}
180
@@ -224,6 +229,10 @@ export default class Store extends EventEmitter {
229
this.emit('importedProfilingData');
230
}
231
232
+ get inspectedElementCache(): InspectedElementCache {
233
+ return this._inspectedElementCache;
234
+ }
235
+
236
get isProfiling(): boolean {
237
return this._isProfiling;
238
}
src/devtools/views/Components/Components.css
+10
@@ -24,3 +24,13 @@
24
flex-direction: column;
25
}
26
}
27
+
28
+.Loading {
29
+ height: 100%;
30
+ padding-left: 0.5rem;
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ font-size: var(--font-size-sans-large);
35
+ color: var(--color-dim);
36
+}
src/devtools/views/Components/Components.js
+8
-2
@@ -1,6 +1,6 @@
1
// @flow
2
3
-import React from 'react';
3
+import React, { Suspense } from 'react';
4
import Tree from './Tree';
5
import SelectedElement from './SelectedElement';
6
import styles from './Components.css';
@@ -14,10 +14,16 @@ function Components(_: {||}) {
14
<Tree />
15
</div>
16
<div className={styles.SelectedElementWrapper}>
17
- <SelectedElement />
17
+ <Suspense fallback={<Loading />}>
18
+ <SelectedElement />
19
+ </Suspense>
20
</div>
21
</div>
22
);
23
}
24
25
+function Loading() {
26
+ return <div className={styles.Loading}>Loading...</div>;
27
+}
28
+
29
export default portaledContent(Components);
src/devtools/views/Components/SelectedElement.js
+6
-88
@@ -1,19 +1,12 @@
1
// @flow
2
3
-import React, {
4
- useCallback,
5
- useContext,
6
- useEffect,
7
- useRef,
8
- useState,
9
-} from 'react';
3
+import React, { useCallback, useContext } from 'react';
4
import { TreeContext } from './TreeContext';
5
import { BridgeContext, StoreContext } from '../context';
6
import Button from '../Button';
7
import ButtonIcon from '../ButtonIcon';
8
import HooksTree from './HooksTree';
9
import InspectedElementTree from './InspectedElementTree';
16
-import { hydrate } from 'src/hydration';
10
import styles from './SelectedElement.css';
11
import {
12
ElementTypeClass,
@@ -23,8 +16,7 @@ import {
16
ElementTypeSuspense,
17
} from '../../types';
18
26
-import type { InspectedElement } from './types';
27
-import type { DehydratedData, Element } from './types';
19
+import type { Element, InspectedElement } from './types';
20
21
export type Props = {||};
22
@@ -36,7 +28,10 @@ export default function SelectedElement(_: Props) {
28
const element =
29
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
30
39
- const inspectedElement = useInspectedElement(selectedElementID);
31
+ const inspectedElement =
32
+ selectedElementID != null
33
+ ? store.inspectedElementCache.read(selectedElementID)
34
+ : null;
35
36
const highlightElement = useCallback(() => {
37
if (element !== null && selectedElementID !== null) {
@@ -261,80 +256,3 @@ function OwnerView({ displayName, id }: { displayName: string, id: number }) {
256
</button>
257
);
258
}
264
-
265
-function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
266
- if (dehydratedData !== null) {
267
- return hydrate(dehydratedData.data, dehydratedData.cleaned);
268
- } else {
269
- return null;
270
- }
271
-}
272
-
273
-function useInspectedElement(id: number | null): InspectedElement | null {
274
- const idRef = useRef(id);
275
- const bridge = useContext(BridgeContext);
276
- const store = useContext(StoreContext);
277
-
278
- const [inspectedElement, setInspectedElement] = useState(null);
279
-
280
- useEffect(() => {
281
- // Track the current selected element ID.
282
- // We ignore any backend updates about previously selected elements.
283
- idRef.current = id;
284
-
285
- // Hide previous/stale insepected element to avoid temporarily showing the wrong values.
286
- setInspectedElement(null);
287
-
288
- // A null id indicates that there's nothing currently selected in the tree.
289
- if (id === null) {
290
- return () => {};
291
- }
292
-
293
- const rendererID = store.getRendererIDForElement(id);
294
-
295
- // Update the $r variable.
296
- bridge.send('selectElement', { id, rendererID });
297
-
298
- // Update props, state, and context in the side panel.
299
- const sendBridgeRequest = () => {
300
- bridge.send('inspectElement', { id, rendererID });
301
- };
302
-
303
- let timeoutID = null;
304
-
305
- const onInspectedElement = (inspectedElement: InspectedElement) => {
306
- if (!inspectedElement || inspectedElement.id !== idRef.current) {
307
- // Ignore bridge updates about previously selected elements.
308
- return;
309
- }
310
-
311
- if (inspectedElement !== null) {
312
- inspectedElement.context = hydrateHelper(inspectedElement.context);
313
- inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
314
- inspectedElement.props = hydrateHelper(inspectedElement.props);
315
- inspectedElement.state = hydrateHelper(inspectedElement.state);
316
- }
317
-
318
- setInspectedElement(inspectedElement);
319
-
320
- // Ask for an update in a second.
321
- // Make sure we only ask once though.
322
- clearTimeout(((timeoutID: any): TimeoutID));
323
- timeoutID = setTimeout(sendBridgeRequest, 1000);
324
- };
325
-
326
- bridge.addListener('inspectedElement', onInspectedElement);
327
-
328
- sendBridgeRequest();
329
-
330
- return () => {
331
- bridge.removeListener('inspectedElement', onInspectedElement);
332
-
333
- if (timeoutID !== null) {
334
- clearTimeout(timeoutID);
335
- }
336
- };
337
- }, [bridge, id, idRef, store]);
338
-
339
- return inspectedElement;
340
-}
src/devtools/views/Components/TreeContext.js
+4
@@ -33,6 +33,10 @@ import Store from '../../store';
33
34
import type { Element } from './types';
35
36
+// TODO Use two setState pattern for selecting Fibers:
37
+// The first update should be default priority and should select a new element in the Tree.
38
+// The second update should be deferred priority and should trigger suspense.
39
+
40
type Context = {|
41
// Tree
42
baseDepth: number,