Refactored insepected element cache to use the context API

This commit is contained in:
Brian Vaughn
2019-04-22 09:37:34 -07:00
parent 09b023a6e6
commit c6b19cc141
6 changed files with 170 additions and 130 deletions
-93
View File
@@ -1,93 +0,0 @@
// @flow
import EventEmitter from 'events';
import { createResource } from './cache';
import Store from './store';
import { hydrate } from 'src/hydration';
import type {
DehydratedData,
InspectedElement,
} from 'src/devtools/views/Components/types';
import type { Resource } from './cache';
import type { Bridge } from '../types';
type ResolveFn = (inspectedElement: InspectedElement) => void;
type Params = {|
id: number,
rendererID: number,
|};
// TODO Use an LRU for the underlying caching mechanism, to prevent memory leaks.
// TODO Something needs to poll for (unprompted) updates.
export default class InspectedElementCache extends EventEmitter {
_bridge: Bridge;
_store: Store;
_pendingRequests: Map<number, ResolveFn> = new Map();
_resource: Resource<Params, number, InspectedElement> = createResource(
({ id, rendererID }: Params) => {
return new Promise(resolve => {
this._pendingRequests.set(id, resolve);
this._bridge.send('inspectElement', {
id,
rendererID,
});
});
},
({ id, rendererID }: Params) => id
);
constructor(bridge: Bridge, store: Store) {
super();
this._bridge = bridge;
this._store = store;
bridge.addListener('inspectedElement', this._onInspectedElement);
}
read(id: number): InspectedElement | null {
const rendererID = this._store.getRendererIDForElement(id);
if (rendererID != null) {
return this._resource.read({ id, rendererID });
} else {
return null;
}
}
_onInspectedElement = (inspectedElement: InspectedElement) => {
const id = inspectedElement.id;
if (inspectedElement != null) {
inspectedElement.context = hydrateHelper(inspectedElement.context);
inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
inspectedElement.props = hydrateHelper(inspectedElement.props);
inspectedElement.state = hydrateHelper(inspectedElement.state);
}
const resolveFn = this._pendingRequests.get(id);
if (resolveFn != null) {
this._pendingRequests.delete(id);
resolveFn(inspectedElement);
} else {
this._resource.write(id, inspectedElement);
this.emit('invalidated', id);
}
};
}
function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
if (dehydratedData !== null) {
return hydrate(dehydratedData.data, dehydratedData.cleaned);
} else {
return null;
}
}
+13 -21
View File
@@ -1,6 +1,7 @@
// @flow
import React, { createContext } from 'react';
import LRU from 'lru-cache';
// Cache implementation was forked from the React repo:
// https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js
@@ -63,10 +64,6 @@ function readContext(Context, observedBits) {
return dispatcher.readContext(Context, observedBits);
}
function identityHashFn(input) {
return input;
}
const CacheContext = createContext(null);
const entries: Map<Resource<any, any, any>, Map<any, any>> = new Map();
@@ -77,12 +74,8 @@ function accessResult<Input, Key, Value>(
input: Input,
key: Key
): Result<Value> {
let entriesForResource = entries.get(resource);
if (entriesForResource === undefined) {
entriesForResource = new Map();
entries.set(resource, entriesForResource);
}
let entry = entriesForResource.get(key);
const entriesForResource = ((entries.get(resource): any): Map<any, any>);
const entry = entriesForResource.get(key);
if (entry === undefined) {
const thenable = fetch(input);
thenable.then(
@@ -114,16 +107,16 @@ function accessResult<Input, Key, Value>(
export function createResource<Input, Key: string | number, Value>(
fetch: Input => Thenable<Value>,
maybeHashInput?: Input => Key
hashInput: Input => Key,
useLRU?: boolean = false
): Resource<Input, Key, Value> {
const hashInput: Input => Key =
maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
const resource = {
invalidate(key: Key): void {
const entriesForResource = entries.get(resource);
if (entriesForResource !== undefined) {
const entriesForResource = ((entries.get(resource): any): Map<any, any>);
if (entriesForResource instanceof Map) {
entriesForResource.delete(key);
} else {
entriesForResource.set(key, undefined);
}
},
@@ -163,14 +156,13 @@ export function createResource<Input, Key: string | number, Value>(
},
write(key: Key, value: Value): void {
let entriesForResource = entries.get(resource);
if (entriesForResource === undefined) {
entriesForResource = new Map();
entries.set(resource, entriesForResource);
}
const entriesForResource = ((entries.get(resource): any): Map<any, any>);
entriesForResource.set(key, value);
},
};
entries.set(resource, useLRU ? new LRU({ max: 10 }) : new Map());
return resource;
}
-9
View File
@@ -13,7 +13,6 @@ import { ElementTypeRoot } from './types';
import { utfDecodeString } from '../utils';
import { __DEBUG__ } from '../constants';
import ProfilingCache from './ProfilingCache';
import InspectedElementCache from './InspectedElementCache';
import type { ElementType } from './types';
import type { Element } from './views/Components/types';
@@ -76,9 +75,6 @@ export default class Store extends EventEmitter {
// The user has imported a previously exported profiling session.
_importedProfilingData: ImportedProfilingData | null = null;
// Suspense cache for lazy-loaded inspected Element data.
_inspectedElementCache: InspectedElementCache;
// The backend is currently profiling.
// When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
_isProfiling: boolean = false;
@@ -174,7 +170,6 @@ export default class Store extends EventEmitter {
// so the frontend needs to ask the backend for its status after mounting.
bridge.send('getProfilingStatus');
this._inspectedElementCache = new InspectedElementCache(bridge, this);
this._profilingCache = new ProfilingCache(bridge, this);
}
@@ -229,10 +224,6 @@ export default class Store extends EventEmitter {
this.emit('importedProfilingData');
}
get inspectedElementCache(): InspectedElementCache {
return this._inspectedElementCache;
}
get isProfiling(): boolean {
return this._isProfiling;
}
+8 -4
View File
@@ -3,9 +3,11 @@
import React, { Suspense } from 'react';
import Tree from './Tree';
import SelectedElement from './SelectedElement';
import styles from './Components.css';
import { InspectedElementContextController } from './InspectedElementContext';
import portaledContent from '../portaledContent';
import styles from './Components.css';
function Components(_: {||}) {
// TODO Flex wrappers below should be user resizable.
return (
@@ -14,9 +16,11 @@ function Components(_: {||}) {
<Tree />
</div>
<div className={styles.SelectedElementWrapper}>
<Suspense fallback={<Loading />}>
<SelectedElement />
</Suspense>
<InspectedElementContextController>
<Suspense fallback={<Loading />}>
<SelectedElement />
</Suspense>
</InspectedElementContextController>
</div>
</div>
);
@@ -0,0 +1,145 @@
// @flow
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { createResource } from '../../cache';
import { BridgeContext, StoreContext } from '../context';
import { hydrate } from 'src/hydration';
import type {
DehydratedData,
InspectedElement,
} from 'src/devtools/views/Components/types';
import type { Resource } from '../../cache';
// TODO Something needs to poll for (unprompted) updates.
// TODO The curretn approach caches resources permanently.
// We won't even ask for an update if an element is reselected.
// I think we need to separate the polling for an update from the suspense cache.
// This way we can always resened (and poll on an interval) for the selected id,
// and the cache here can just invalidate itself as responses stream in.
type Params = {|
id: number,
rendererID: number,
|};
type Context = {|
read(id: number): InspectedElement | null,
|};
const InspectedElementContext = createContext<Context>(((null: any): Context));
InspectedElementContext.displayName = 'InspectedElementContext';
type ResolveFn = (inspectedElement: InspectedElement) => void;
type InProgressRequest = {|
promise: Promise<InspectedElement>,
resolveFn: ResolveFn,
|};
type Props = {|
children: React$Node,
|};
function InspectedElementContextController({ children }: Props) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const [count, setCount] = useState(0);
const inProgressRequests = useMemo<Map<number, InProgressRequest>>(
() => new Map(),
[]
);
const resource = useMemo<Resource<Params, number, InspectedElement>>(
() =>
createResource(
({ id, rendererID }: Params) => {
let request = inProgressRequests.get(id);
if (request != null) {
return request.promise;
}
let resolveFn = ((null: any): ResolveFn);
const promise = new Promise(resolve => {
resolveFn = resolve;
bridge.send('inspectElement', { id, rendererID });
});
inProgressRequests.set(id, { promise, resolveFn });
return promise;
},
({ id, rendererID }: Params) => id
),
[bridge, inProgressRequests]
);
useEffect(() => {
const onInspectedElement = (inspectedElement: InspectedElement | null) => {
if (inspectedElement != null) {
const id = inspectedElement.id;
inspectedElement.context = hydrateHelper(inspectedElement.context);
inspectedElement.hooks = hydrateHelper(inspectedElement.hooks);
inspectedElement.props = hydrateHelper(inspectedElement.props);
inspectedElement.state = hydrateHelper(inspectedElement.state);
const request = inProgressRequests.get(id);
if (request != null) {
inProgressRequests.delete(id);
request.resolveFn(inspectedElement);
} else {
resource.write(id, inspectedElement);
// Schedule update with React.
setCount(count => count + 1);
}
}
};
bridge.addListener('inspectedElement', onInspectedElement);
return () => bridge.removeListener('inspectElement', onInspectedElement);
}, [bridge, inProgressRequests, resource]);
const read = useCallback(
(id: number) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
return resource.read({ id, rendererID });
} else {
return null;
}
},
[resource, store]
);
// "count" is intentionally passed so that it recreates the memoized object.
// eslint-disable-next-line react-hooks/exhaustive-deps
const value = useMemo(() => ({ read }), [count, read]);
return (
<InspectedElementContext.Provider value={value}>
{children}
</InspectedElementContext.Provider>
);
}
function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
if (dehydratedData !== null) {
return hydrate(dehydratedData.data, dehydratedData.cleaned);
} else {
return null;
}
}
export { InspectedElementContext, InspectedElementContextController };
@@ -7,6 +7,7 @@ import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import HooksTree from './HooksTree';
import InspectedElementTree from './InspectedElementTree';
import { InspectedElementContext } from './InspectedElementContext';
import styles from './SelectedElement.css';
import {
ElementTypeClass,
@@ -25,13 +26,13 @@ export default function SelectedElement(_: Props) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const { read } = useContext(InspectedElementContext);
const element =
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
const inspectedElement =
selectedElementID != null
? store.inspectedElementCache.read(selectedElementID)
: null;
selectedElementID != null ? read(selectedElementID) : null;
const highlightElement = useCallback(() => {
if (element !== null && selectedElementID !== null) {