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) {