Stashing partial windowing implementation

This commit is contained in:
Brian Vaughn
2019-01-27 16:04:17 -08:00
parent b7cbd38214
commit c9a5f8cc29
10 changed files with 322 additions and 83 deletions
+1 -1
View File
@@ -90,7 +90,7 @@
"react-dom": "^16.8.0-alpha.1",
"react-portal": "^3.1.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.1.1",
"react-window": "^1.5.0",
"semver": "^5.5.1",
"style-loader": "^0.23.1",
"web-ext": "^1.10.1",
+4 -1
View File
@@ -15,7 +15,7 @@ import type { Bridge } from '../types';
import type { Element } from 'src/devtools/types';
const debug = (methodName, ...args) => {
// debug(`%cAgent %c${methodName}`, 'color: blue; font-weight: bold;', 'font-weight: bold;', ...args);
console.log(`%cAgent %c${methodName}`, 'color: blue; font-weight: bold;', 'font-weight: bold;', ...args);
};
const THROTTLE_BY_MS = 350;
@@ -34,6 +34,7 @@ export default class Agent extends EventEmitter {
// bridge.on('...'), this...);
this.addListener('root', id => bridge.send('root', id));
this.addListener('rootCommitted', id => bridge.send('rootCommitted', id));
this.addListener('mount', data => bridge.send('mount', data));
this.addListener('update', data => bridge.send('update', data));
this.addListener('unmount', data => bridge.send('unmount', data));
@@ -137,6 +138,8 @@ export default class Agent extends EventEmitter {
debug('emit("root")', id);
this.emit('root', id);
}
this.emit('rootCommitted', id);
};
onHookUnmount = ({ fiber }: { fiber: Fiber }) => {
+205 -8
View File
@@ -2,11 +2,11 @@
import EventEmitter from 'events';
import type { Element } from './types';
import type { Element, ElementTreeMetadata } from './types';
import type { Bridge } from '../types';
const debug = (methodName, ...args) => {
// console.log(`%cAgent %c${methodName}`, 'color: red; font-weight: bold;', 'font-weight: bold;', ...args);
console.log(`%cStore %c${methodName}`, 'color: red; font-weight: bold;', 'font-weight: bold;', ...args);
};
/**
@@ -14,8 +14,17 @@ const debug = (methodName, ...args) => {
* ContextProviders can subscribe to the Store for specific things they want to provide.
*/
class Store extends EventEmitter {
_elementToElementTreeMetadata: WeakMap<
Element,
ElementTreeMetadata
> = new WeakMap();
_idToElement: Map<string, Element> = new Map();
_idToParentID: Map<string, string> = new Map();
_pendingDeletions: Set<string> = new Set();
// Total number of visible elements (within all roots).
// Used for windowing purposes.
numElements: number = 0;
// This Array must be treated as immutable!
// Passive effects will check it for changes between render and mount.
@@ -25,12 +34,54 @@ class Store extends EventEmitter {
super();
bridge.on('root', this.onBridgeRoot);
bridge.on('rootCommitted', this.onBridgeRootCommitted);
bridge.on('mount', this.onBridgeMount);
bridge.on('update', this.onBridgeUpdated);
bridge.on('unmount', this.onBridgeUnmounted);
}
getElement(id: string) {
getElementAtIndex(index: number): Element | null {
if (index < 0 || index >= this.numElements) {
return null;
}
let rootID;
let root;
let rootWeight = 0;
for (let i = 0; i < this.roots.length; i++) {
rootID = this.roots[i];
root = this._idToElement.get(rootID);
const { weight } = this._elementToElementTreeMetadata.get(root);
if (rootWeight + weight > index) {
break;
}
}
let currentElement = root;
let currentWeight = 0;
while (index !== currentWeight) {
for (let i = 0; i < currentElement.children.length; i++) {
const childID = currentElement.children[i];
const child = this._idToElement.get(childID);
const { weight } = this._elementToElementTreeMetadata.get(child);
if (index <= currentWeight + weight) {
currentWeight++;
currentElement = child;
break;
} else {
currentWeight += weight;
}
}
}
return currentElement;
}
getElementByID(id: string) {
return this._idToElement.get(id);
}
@@ -38,6 +89,117 @@ class Store extends EventEmitter {
return this._idToParentID.get(id);
}
getTreeMetadataForElement(element: Element) {
return this._elementToElementTreeMetadata.get(element);
}
_crawlForTreeMetadata(id: string, depth: number = 0): number {
let weight = 1;
const element = this._idToElement.get(id);
// TODO: Figure out why sometimes items aren't being sent across the bridge.
// It always seems to be one of the ListItems...
if (element == null) {
console.log(`%cNo element found for id "${id}"`, 'background-color: yellow; font-weight: bold;');
return 0;
}
element.children.forEach(childID => {
weight += this._crawlForTreeMetadata(childID, depth + 1);
});
this._elementToElementTreeMetadata.set(element, {
depth,
weight,
});
return weight;
}
_updateElementTreeMetadata(prevElement: Element, element: Element): void {
if (prevElement.children === element.children) {
return;
}
// Compare children in case they have changed.
// For each child that was removed, we need to shrink the list by this many elements.
// For each child that was added, we need to grow the list by this many elements.
const prevChildren = prevElement.children;
const prevNumChildren = prevChildren.length;
const children = element.children;
const numChildren = children.length;
// TODO: The below diffing could be optimized more.
// Scan for deletions
for (let i = 0; i < prevNumChildren; i++) {
const childID = prevChildren[i];
if (!children.includes(childID)) {
const child = this._idToElement.get(childID);
const { weight } = this._elementToElementTreeMetadata.get(child);
this.numElements -= weight;
let current = element;
while (current !== null) {
const datum = this._elementToElementTreeMetadata.get(current);
datum.weight -= weight;
const parent = this._idToElement.get(datum.parentID);
current =
parent != null
? this._elementToElementTreeMetadata.get(parent)
: null;
}
}
}
// Scan for additions
for (let i = 0; i < numChildren; i++) {
const childID = children[i];
if (!prevChildren.includes(childID)) {
const child = this._idToElement.get(childID);
const { depth } = this._elementToElementTreeMetadata.get(element);
const weight = this._crawlForTreeMetadata(
childID,
depth + 1
);
this.numElements += weight;
let current = element;
while (current !== null) {
const datum = this._elementToElementTreeMetadata.get(current);
datum.weight += weight;
const parent = this._idToElement.get(datum.parentID);
current =
parent != null
? this._elementToElementTreeMetadata.get(parent)
: null;
}
}
}
}
__printTree() {
let i = 0;
this.roots.forEach(rootID => {
const root = this._idToElement.get(rootID);
const { weight } = this._elementToElementTreeMetadata.get(root);
for (let j = i; j < i + weight; j++) {
const element = this.getElementAtIndex(j)
const { depth } = this._elementToElementTreeMetadata.get(element);
console.log(' '.repeat(depth) + element.displayName);
}
i += weight;
});
}
onBridgeMount = (element: Element) => {
const { id } = element;
debug('onBridgeMount()', element);
@@ -54,23 +216,47 @@ class Store extends EventEmitter {
debug('onBridgeRoot()', id);
if (!this.roots.includes(id)) {
this.roots = this.roots.concat(id);
// Generate tree metadata used for windowing.
this.numElements += this._crawlForTreeMetadata(id);
this.emit('roots');
}
};
onBridgeRootCommitted = (rootID: string) => {
this._pendingDeletions.forEach(id => {
this._idToElement.delete(id);
if (this._idToParentID.has(id)) {
this._idToParentID.delete(id);
}
});
this._pendingDeletions.clear();
debug('onBridgeRootCommitted()', rootID);
this.emit('rootCommitted', rootID);
this.__printTree();
};
// TODO: Unmounting removes id-to-element before crawling, which breaks it.
// Should I just ditch the idea of a WeakMap in favor of an explicit it-to-metadata mapping like with parents?
onBridgeUnmounted = (id: string) => {
debug('onBridgeUnmounted()', id);
this._idToElement.delete(id);
if (this._idToParentID.has(id)) {
this._idToParentID.delete(id);
}
this._pendingDeletions.add(id);
const index = this.roots.indexOf(id);
if (index >= 0) {
this.roots = this.roots
.slice(0, index)
.concat(this.roots.slice(index + 1));
const root = this._idToElement.get(id);
const {weight} = this._elementToElementTreeMetadata.get(root);
this.numElements -= weight;
this.emit('roots');
}
};
@@ -78,7 +264,18 @@ class Store extends EventEmitter {
onBridgeUpdated = (element: Element) => {
const { id } = element;
debug('onBridgeUpdated()', element);
const prevElement = ((this._idToElement.get(id): any): Element);
const prevElementTreeMetadata = ((this._elementToElementTreeMetadata.get(prevElement): any): ElementTreeMetadata);
this._idToElement.set(id, element);
this._elementToElementTreeMetadata.set(
element,
prevElementTreeMetadata
);
// Update tree metadata used for windowing.
this._updateElementTreeMetadata(prevElement, element);
this.emit(id);
};
+13
View File
@@ -1,5 +1,7 @@
// @flow
import typeof Store from './Store';
export const ElementTypeClassOrFunction = 1;
export const ElementTypeContext = 2;
export const ElementTypeForwardRef = 3;
@@ -20,6 +22,12 @@ export type Element = {|
children: Array<string>,
|};
export type ElementTreeMetadata = {|
depth: number,
rootID: string,
weight: number,
|};
export type InspectedElement = {|
id: string,
context: Object | null,
@@ -29,3 +37,8 @@ export type InspectedElement = {|
canEditProps: boolean,
source: Object,
|};
export type TreeContext = {|
size: number,
store: Store,
|};
+27 -28
View File
@@ -1,19 +1,18 @@
// @flow
import React, { Fragment, useContext } from 'react';
import { useElement } from './hooks';
import { StoreContext } from './contexts';
import { TreeContext } from './contexts';
import styles from './Element.css';
type Props = {|
depth: number,
id: string,
index: number,
style: Object,
|};
export default function Element({ depth, id }: Props) {
const store = useContext(StoreContext);
const element = useElement(store, id);
export default function Element({ index, style }: Props) {
const {store} = useContext(TreeContext);
const element = store.getElementAtIndex(index);
// DevTools are rendered in concurrent mode.
// It's possible the store has updated since the commit that triggered this render.
@@ -23,32 +22,32 @@ export default function Element({ depth, id }: Props) {
return null;
}
const elementTreeMetadata = store.getTreeMetadataForElement(element);
const { children, displayName, key } = element;
const { depth } = elementTreeMetadata;
// TODO: Add state for toggling element open/close
return (
<Fragment>
<div
className={styles.Element}
style={{ paddingLeft: `${1 + depth}rem` }}
>
{children.length > 0 && <span className={styles.ArrowOpen} />}
<div
className={styles.Element}
style={{
...style,
paddingLeft: `${1 + depth}rem`
}}
>
{children.length > 0 && <span className={styles.ArrowOpen} />}
<span className={styles.Component}>
{displayName}
{key && (
<Fragment>
&nbsp;<span className={styles.AttributeName}>key</span>=
<span className={styles.AttributeValue}>"{key}"</span>
</Fragment>
)}
</span>
</div>
{children.map(childID => (
<Element key={childID} depth={depth + 1} id={childID} />
))}
</Fragment>
<span className={styles.Component}>
{displayName}
{key && (
<Fragment>
&nbsp;<span className={styles.AttributeName}>key</span>=
<span className={styles.AttributeValue}>"{key}"</span>
</Fragment>
)}
</span>
</div>
);
}
+25 -5
View File
@@ -1,9 +1,9 @@
// @flow
import React, { useMemo } from 'react';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import Store from '../store';
import Tree from './Tree';
import { StoreContext } from './contexts';
import { StoreContext, TreeContext } from './contexts';
import styles from './Elements.css';
import type { Bridge } from '../../types';
@@ -17,11 +17,31 @@ export type Props = {|
export default function Elements({ bridge, browserName, themeName }: Props) {
const store = useMemo(() => new Store(bridge), []);
const [treeContext, setTreeContext] = useState(({
size: store.numElements,
store,
}));
useLayoutEffect(() => {
const handler = () => {
setTreeContext({
size: store.numElements,
store,
});
};
store.addListener('rootCommitted', handler);
return () => store.removeListener('rootCommitted', handler);
}, [store]);
return (
<StoreContext.Provider value={store}>
<div className={styles.Elements}>
<Tree />
</div>
<TreeContext.Provider value={treeContext}>
<div className={styles.Elements}>
<Tree />
</div>
</TreeContext.Provider>
</StoreContext.Provider>
);
}
+21 -33
View File
@@ -1,44 +1,32 @@
// @flow
import React, { useContext } from 'react';
import React, { useContext, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import Element from './Element';
import { StoreContext } from './contexts';
import { useElement, useRoots } from './hooks';
import { TreeContext } from './contexts';
import styles from './Tree.css';
type TreeProps = {||};
type Props = {||};
export default function Tree(props: TreeProps) {
const store = useContext(StoreContext);
const roots = useRoots(store);
export default function Tree(props: Props) {
const treeContext = useContext(TreeContext);
return (
<div className={styles.Tree}>
{roots.map(id => (
<Root key={id} id={id} />
))}
</div>
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
className={styles.Tree}
height={height}
itemCount={treeContext.size}
itemData={treeContext}
itemSize={20}
width={width}
>
{Element}
</FixedSizeList>
)}
</AutoSizer>
);
}
type RootProps = {|
id: string,
|};
function Root({ id }: RootProps) {
const store = useContext(StoreContext);
const element = useElement(store, id);
// DevTools are rendered in concurrent mode.
// It's possible the store has updated since the commit that triggered this render.
// So we need to guard against an undefined element.
// TODO: Handle this by switching to a Suspense based approach.
if (element == null) {
return null;
}
return element.children.map(childID => (
<Element key={childID} depth={0} id={childID} />
));
}
+3
View File
@@ -2,7 +2,10 @@
import { createContext } from 'react';
import type { TreeMetadata } from '../types';
import Store from '../store';
export const RootsContext = createContext<Array<string>>([]);
export const StoreContext = createContext<Store>(((null: any): Store));
export const TreeContext = createContext<TreeMetadata>(((null: any): TreeMetadata));
+5 -4
View File
@@ -2,7 +2,7 @@
import { useLayoutEffect, useState } from 'react';
import type { Element } from '../types';
import type { Element, ElementTreeMetadata } from '../types';
import Store from '../store';
// TODO useEffect has a bug where sometimes subscriptions don't get cleaned up correctly.
@@ -10,16 +10,17 @@ import Store from '../store';
// As a temporary work around, switch back to layout effect.
export function useElement(store: Store, id: string): ?Element {
const [element, setElement] = useState<?Element>(store.getElement(id));
const [element, setElement] = useState<?Element>(store.getElementByID(id));
useLayoutEffect(() => {
const handler = () => setElement(((store.getElement(id): any): Element));
const handler = () =>
setElement(((store.getElementByID(id): any): Element));
// Listen for changes to the element.
store.addListener(id, handler);
// Check for changes that may have happened between render and mount.
const newElement = store.getElement(id);
const newElement = store.getElementByID(id);
if (element !== newElement) {
setElement(newElement);
}
+18 -3
View File
@@ -674,6 +674,13 @@
"@babel/plugin-transform-react-jsx-self" "^7.0.0"
"@babel/plugin-transform-react-jsx-source" "^7.0.0"
"@babel/runtime@^7.0.0":
version "7.3.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a"
integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA==
dependencies:
regenerator-runtime "^0.12.0"
"@babel/template@^7.1.0", "@babel/template@^7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644"
@@ -8413,11 +8420,14 @@ react-portal@^3.1.0:
react-virtualized-auto-sizer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
react-window@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.1.1.tgz#8a0cf488c9db19425fb804f118f5aac6227d7fd2"
react-window@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.5.0.tgz#3e56b720b97666bce38a9e932bdd238d56e258f1"
integrity sha512-55WeZKjMNF5JdCuKghc/H65DBecoeGgH8MOX3CgT7BJ66xb4ITRuXPUlz0qU6r50wetdF/oLhorYBRvKD4Z1IQ==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one "^3.1.1"
react@^16.8.0-alpha.1:
@@ -8586,6 +8596,11 @@ regenerator-runtime@^0.11.0:
version "0.11.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
regenerator-runtime@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.9.5:
version "0.9.6"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz#d33eb95d0d2001a4be39659707c51b0cb71ce029"