@samitouri / QOS-React / commits / c6b19cc141

Refactored insepected element cache to use the context API

Brian Vaughn committed Apr 20, 2019 at 08:33 UTC c6b19cc1416070e341a7a9be2c160a8472b7577b
6 files changed +170 -130
src/devtools/InspectedElementCache.js deleted
-93
@@ -1,93 +0,0 @@
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/cache.js
+13 -21
@@ -1,6 +1,7 @@
1 // @flow
2
3 import React, { createContext } from 'react';
4 +import LRU from 'lru-cache';
5
6 // Cache implementation was forked from the React repo:
7 // https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js
@@ -63,10 +64,6 @@ function readContext(Context, observedBits) {
64 return dispatcher.readContext(Context, observedBits);
65 }
66
66 -function identityHashFn(input) {
67 - return input;
68 -}
69 -
67 const CacheContext = createContext(null);
68
69 const entries: Map<Resource<any, any, any>, Map<any, any>> = new Map();
@@ -77,12 +74,8 @@ function accessResult<Input, Key, Value>(
74 input: Input,
75 key: Key
76 ): Result<Value> {
80 - let entriesForResource = entries.get(resource);
81 - if (entriesForResource === undefined) {
82 - entriesForResource = new Map();
83 - entries.set(resource, entriesForResource);
84 - }
85 - let entry = entriesForResource.get(key);
77 + const entriesForResource = ((entries.get(resource): any): Map<any, any>);
78 + const entry = entriesForResource.get(key);
79 if (entry === undefined) {
80 const thenable = fetch(input);
81 thenable.then(
@@ -114,16 +107,16 @@ function accessResult<Input, Key, Value>(
107
108 export function createResource<Input, Key: string | number, Value>(
109 fetch: Input => Thenable<Value>,
117 - maybeHashInput?: Input => Key
110 + hashInput: Input => Key,
111 + useLRU?: boolean = false
112 ): Resource<Input, Key, Value> {
119 - const hashInput: Input => Key =
120 - maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
121 -
113 const resource = {
114 invalidate(key: Key): void {
124 - const entriesForResource = entries.get(resource);
125 - if (entriesForResource !== undefined) {
115 + const entriesForResource = ((entries.get(resource): any): Map<any, any>);
116 + if (entriesForResource instanceof Map) {
117 entriesForResource.delete(key);
118 + } else {
119 + entriesForResource.set(key, undefined);
120 }
121 },
122
@@ -163,14 +156,13 @@ export function createResource<Input, Key: string | number, Value>(
156 },
157
158 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 - }
159 + const entriesForResource = ((entries.get(resource): any): Map<any, any>);
160 entriesForResource.set(key, value);
161 },
162 };
163 +
164 + entries.set(resource, useLRU ? new LRU({ max: 10 }) : new Map());
165 +
166 return resource;
167 }
168
src/devtools/store.js
-9
@@ -13,7 +13,6 @@ import { ElementTypeRoot } from './types';
13 import { utfDecodeString } from '../utils';
14 import { __DEBUG__ } from '../constants';
15 import ProfilingCache from './ProfilingCache';
16 -import InspectedElementCache from './InspectedElementCache';
16
17 import type { ElementType } from './types';
18 import type { Element } from './views/Components/types';
@@ -76,9 +75,6 @@ export default class Store extends EventEmitter {
75 // The user has imported a previously exported profiling session.
76 _importedProfilingData: ImportedProfilingData | null = null;
77
79 - // Suspense cache for lazy-loaded inspected Element data.
80 - _inspectedElementCache: InspectedElementCache;
81 -
78 // The backend is currently profiling.
79 // When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
80 _isProfiling: boolean = false;
@@ -174,7 +170,6 @@ export default class Store extends EventEmitter {
170 // so the frontend needs to ask the backend for its status after mounting.
171 bridge.send('getProfilingStatus');
172
177 - this._inspectedElementCache = new InspectedElementCache(bridge, this);
173 this._profilingCache = new ProfilingCache(bridge, this);
174 }
175
@@ -229,10 +224,6 @@ export default class Store extends EventEmitter {
224 this.emit('importedProfilingData');
225 }
226
232 - get inspectedElementCache(): InspectedElementCache {
233 - return this._inspectedElementCache;
234 - }
235 -
227 get isProfiling(): boolean {
228 return this._isProfiling;
229 }
src/devtools/views/Components/Components.js
+8 -4
@@ -3,9 +3,11 @@
3 import React, { Suspense } from 'react';
4 import Tree from './Tree';
5 import SelectedElement from './SelectedElement';
6 -import styles from './Components.css';
6 +import { InspectedElementContextController } from './InspectedElementContext';
7 import portaledContent from '../portaledContent';
8
9 +import styles from './Components.css';
10 +
11 function Components(_: {||}) {
12 // TODO Flex wrappers below should be user resizable.
13 return (
@@ -14,9 +16,11 @@ function Components(_: {||}) {
16 <Tree />
17 </div>
18 <div className={styles.SelectedElementWrapper}>
17 - <Suspense fallback={<Loading />}>
18 - <SelectedElement />
19 - </Suspense>
19 + <InspectedElementContextController>
20 + <Suspense fallback={<Loading />}>
21 + <SelectedElement />
22 + </Suspense>
23 + </InspectedElementContextController>
24 </div>
25 </div>
26 );
src/devtools/views/Components/InspectedElementContext.js new
+145
@@ -0,0 +1,145 @@
1 +// @flow
2 +
3 +import React, {
4 + createContext,
5 + useCallback,
6 + useContext,
7 + useEffect,
8 + useMemo,
9 + useState,
10 +} from 'react';
11 +import { createResource } from '../../cache';
12 +import { BridgeContext, StoreContext } from '../context';
13 +import { hydrate } from 'src/hydration';
14 +
15 +import type {
16 + DehydratedData,
17 + InspectedElement,
18 +} from 'src/devtools/views/Components/types';
19 +import type { Resource } from '../../cache';
20 +
21 +// TODO Something needs to poll for (unprompted) updates.
22 +
23 +// TODO The curretn approach caches resources permanently.
24 +// We won't even ask for an update if an element is reselected.
25 +// I think we need to separate the polling for an update from the suspense cache.
26 +// This way we can always resened (and poll on an interval) for the selected id,
27 +// and the cache here can just invalidate itself as responses stream in.
28 +
29 +type Params = {|
30 + id: number,
31 + rendererID: number,
32 +|};
33 +
34 +type Context = {|
35 + read(id: number): InspectedElement | null,
36 +|};
37 +
38 +const InspectedElementContext = createContext<Context>(((null: any): Context));
39 +InspectedElementContext.displayName = 'InspectedElementContext';
40 +
41 +type ResolveFn = (inspectedElement: InspectedElement) => void;
42 +type InProgressRequest = {|
43 + promise: Promise<InspectedElement>,
44 + resolveFn: ResolveFn,
45 +|};
46 +
47 +type Props = {|
48 + children: React$Node,
49 +|};
50 +
51 +function InspectedElementContextController({ children }: Props) {
52 + const bridge = useContext(BridgeContext);
53 + const store = useContext(StoreContext);
54 +
55 + const [count, setCount] = useState(0);
56 +
57 + const inProgressRequests = useMemo<Map<number, InProgressRequest>>(
58 + () => new Map(),
59 + []
60 + );
61 +
62 + const resource = useMemo<Resource<Params, number, InspectedElement>>(
63 + () =>
64 + createResource(
65 + ({ id, rendererID }: Params) => {
66 + let request = inProgressRequests.get(id);
67 + if (request != null) {
68 + return request.promise;
69 + }
70 +
71 + let resolveFn = ((null: any): ResolveFn);
72 + const promise = new Promise(resolve => {
73 + resolveFn = resolve;
74 +
75 + bridge.send('inspectElement', { id, rendererID });
76 + });
77 +
78 + inProgressRequests.set(id, { promise, resolveFn });
79 +
80 + return promise;
81 + },
82 + ({ id, rendererID }: Params) => id
83 + ),
84 + [bridge, inProgressRequests]
85 + );
86 +
87 + useEffect(() => {
88 + const onInspectedElement = (inspectedElement: InspectedElement | null) => {
89 + if (inspectedElement != null) {
90 + const id = inspectedElement.id;
91 +
92 + inspectedElement.context = hydrateHelper(inspectedElement.context);
93 + inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
94 + inspectedElement.props = hydrateHelper(inspectedElement.props);
95 + inspectedElement.state = hydrateHelper(inspectedElement.state);
96 +
97 + const request = inProgressRequests.get(id);
98 + if (request != null) {
99 + inProgressRequests.delete(id);
100 + request.resolveFn(inspectedElement);
101 + } else {
102 + resource.write(id, inspectedElement);
103 +
104 + // Schedule update with React.
105 + setCount(count => count + 1);
106 + }
107 + }
108 + };
109 +
110 + bridge.addListener('inspectedElement', onInspectedElement);
111 + return () => bridge.removeListener('inspectElement', onInspectedElement);
112 + }, [bridge, inProgressRequests, resource]);
113 +
114 + const read = useCallback(
115 + (id: number) => {
116 + const rendererID = store.getRendererIDForElement(id);
117 + if (rendererID != null) {
118 + return resource.read({ id, rendererID });
119 + } else {
120 + return null;
121 + }
122 + },
123 + [resource, store]
124 + );
125 +
126 + // "count" is intentionally passed so that it recreates the memoized object.
127 + // eslint-disable-next-line react-hooks/exhaustive-deps
128 + const value = useMemo(() => ({ read }), [count, read]);
129 +
130 + return (
131 + <InspectedElementContext.Provider value={value}>
132 + {children}
133 + </InspectedElementContext.Provider>
134 + );
135 +}
136 +
137 +function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
138 + if (dehydratedData !== null) {
139 + return hydrate(dehydratedData.data, dehydratedData.cleaned);
140 + } else {
141 + return null;
142 + }
143 +}
144 +
145 +export { InspectedElementContext, InspectedElementContextController };
src/devtools/views/Components/SelectedElement.js
+4 -3
@@ -7,6 +7,7 @@ import Button from '../Button';
7 import ButtonIcon from '../ButtonIcon';
8 import HooksTree from './HooksTree';
9 import InspectedElementTree from './InspectedElementTree';
10 +import { InspectedElementContext } from './InspectedElementContext';
11 import styles from './SelectedElement.css';
12 import {
13 ElementTypeClass,
@@ -25,13 +26,13 @@ export default function SelectedElement(_: Props) {
26 const bridge = useContext(BridgeContext);
27 const store = useContext(StoreContext);
28
29 + const { read } = useContext(InspectedElementContext);
30 +
31 const element =
32 selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
33
34 const inspectedElement =
32 - selectedElementID != null
33 - ? store.inspectedElementCache.read(selectedElementID)
34 - : null;
35 + selectedElementID != null ? read(selectedElementID) : null;
36
37 const highlightElement = useCallback(() => {
38 if (element !== null && selectedElementID !== null) {