@samitouri / QOS-React / commits / 09b023a6e6

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,