@samitouri / QOS-React-2 / commits / c9a5f8cc29

Stashing partial windowing implementation

Brian Vaughn committed Jan 27, 2019 at 16:04 UTC c9a5f8cc2974fb158bd4e0010c3a391a427b3448
10 files changed +323 -84
package.json
+1 -1
@@ -90,7 +90,7 @@
90 "react-dom": "^16.8.0-alpha.1",
91 "react-portal": "^3.1.0",
92 "react-virtualized-auto-sizer": "^1.0.2",
93 - "react-window": "^1.1.1",
93 + "react-window": "^1.5.0",
94 "semver": "^5.5.1",
95 "style-loader": "^0.23.1",
96 "web-ext": "^1.10.1",
src/backend/agent.js
+4 -1
@@ -15,7 +15,7 @@ import type { Bridge } from '../types';
15 import type { Element } from 'src/devtools/types';
16
17 const debug = (methodName, ...args) => {
18 - // debug(`%cAgent %c${methodName}`, 'color: blue; font-weight: bold;', 'font-weight: bold;', ...args);
18 + console.log(`%cAgent %c${methodName}`, 'color: blue; font-weight: bold;', 'font-weight: bold;', ...args);
19 };
20
21 const THROTTLE_BY_MS = 350;
@@ -34,6 +34,7 @@ export default class Agent extends EventEmitter {
34 // bridge.on('...'), this...);
35
36 this.addListener('root', id => bridge.send('root', id));
37 + this.addListener('rootCommitted', id => bridge.send('rootCommitted', id));
38 this.addListener('mount', data => bridge.send('mount', data));
39 this.addListener('update', data => bridge.send('update', data));
40 this.addListener('unmount', data => bridge.send('unmount', data));
@@ -137,6 +138,8 @@ export default class Agent extends EventEmitter {
138 debug('emit("root")', id);
139 this.emit('root', id);
140 }
141 +
142 + this.emit('rootCommitted', id);
143 };
144
145 onHookUnmount = ({ fiber }: { fiber: Fiber }) => {
src/devtools/store.js
+205 -8
@@ -2,11 +2,11 @@
2
3 import EventEmitter from 'events';
4
5 -import type { Element } from './types';
5 +import type { Element, ElementTreeMetadata } from './types';
6 import type { Bridge } from '../types';
7
8 const debug = (methodName, ...args) => {
9 - // console.log(`%cAgent %c${methodName}`, 'color: red; font-weight: bold;', 'font-weight: bold;', ...args);
9 + console.log(`%cStore %c${methodName}`, 'color: red; font-weight: bold;', 'font-weight: bold;', ...args);
10 };
11
12 /**
@@ -14,8 +14,17 @@ const debug = (methodName, ...args) => {
14 * ContextProviders can subscribe to the Store for specific things they want to provide.
15 */
16 class Store extends EventEmitter {
17 + _elementToElementTreeMetadata: WeakMap<
18 + Element,
19 + ElementTreeMetadata
20 + > = new WeakMap();
21 _idToElement: Map<string, Element> = new Map();
22 _idToParentID: Map<string, string> = new Map();
23 + _pendingDeletions: Set<string> = new Set();
24 +
25 + // Total number of visible elements (within all roots).
26 + // Used for windowing purposes.
27 + numElements: number = 0;
28
29 // This Array must be treated as immutable!
30 // Passive effects will check it for changes between render and mount.
@@ -25,12 +34,54 @@ class Store extends EventEmitter {
34 super();
35
36 bridge.on('root', this.onBridgeRoot);
37 + bridge.on('rootCommitted', this.onBridgeRootCommitted);
38 +
39 bridge.on('mount', this.onBridgeMount);
40 bridge.on('update', this.onBridgeUpdated);
41 bridge.on('unmount', this.onBridgeUnmounted);
42 }
43
33 - getElement(id: string) {
44 + getElementAtIndex(index: number): Element | null {
45 + if (index < 0 || index >= this.numElements) {
46 + return null;
47 + }
48 +
49 + let rootID;
50 + let root;
51 +
52 + let rootWeight = 0;
53 + for (let i = 0; i < this.roots.length; i++) {
54 + rootID = this.roots[i];
55 + root = this._idToElement.get(rootID);
56 + const { weight } = this._elementToElementTreeMetadata.get(root);
57 +
58 + if (rootWeight + weight > index) {
59 + break;
60 + }
61 + }
62 +
63 + let currentElement = root;
64 + let currentWeight = 0;
65 +
66 + while (index !== currentWeight) {
67 + for (let i = 0; i < currentElement.children.length; i++) {
68 + const childID = currentElement.children[i];
69 + const child = this._idToElement.get(childID);
70 + const { weight } = this._elementToElementTreeMetadata.get(child);
71 + if (index <= currentWeight + weight) {
72 + currentWeight++;
73 + currentElement = child;
74 + break;
75 + } else {
76 + currentWeight += weight;
77 + }
78 + }
79 + }
80 +
81 + return currentElement;
82 + }
83 +
84 + getElementByID(id: string) {
85 return this._idToElement.get(id);
86 }
87
@@ -38,6 +89,117 @@ class Store extends EventEmitter {
89 return this._idToParentID.get(id);
90 }
91
92 + getTreeMetadataForElement(element: Element) {
93 + return this._elementToElementTreeMetadata.get(element);
94 + }
95 +
96 + _crawlForTreeMetadata(id: string, depth: number = 0): number {
97 + let weight = 1;
98 +
99 + const element = this._idToElement.get(id);
100 +
101 + // TODO: Figure out why sometimes items aren't being sent across the bridge.
102 + // It always seems to be one of the ListItems...
103 + if (element == null) {
104 + console.log(`%cNo element found for id "${id}"`, 'background-color: yellow; font-weight: bold;');
105 + return 0;
106 + }
107 +
108 + element.children.forEach(childID => {
109 + weight += this._crawlForTreeMetadata(childID, depth + 1);
110 + });
111 +
112 + this._elementToElementTreeMetadata.set(element, {
113 + depth,
114 + weight,
115 + });
116 +
117 + return weight;
118 + }
119 +
120 + _updateElementTreeMetadata(prevElement: Element, element: Element): void {
121 + if (prevElement.children === element.children) {
122 + return;
123 + }
124 +
125 + // Compare children in case they have changed.
126 + // For each child that was removed, we need to shrink the list by this many elements.
127 + // For each child that was added, we need to grow the list by this many elements.
128 +
129 + const prevChildren = prevElement.children;
130 + const prevNumChildren = prevChildren.length;
131 +
132 + const children = element.children;
133 + const numChildren = children.length;
134 +
135 + // TODO: The below diffing could be optimized more.
136 +
137 + // Scan for deletions
138 + for (let i = 0; i < prevNumChildren; i++) {
139 + const childID = prevChildren[i];
140 + if (!children.includes(childID)) {
141 + const child = this._idToElement.get(childID);
142 + const { weight } = this._elementToElementTreeMetadata.get(child);
143 +
144 + this.numElements -= weight;
145 +
146 + let current = element;
147 + while (current !== null) {
148 + const datum = this._elementToElementTreeMetadata.get(current);
149 + datum.weight -= weight;
150 +
151 + const parent = this._idToElement.get(datum.parentID);
152 + current =
153 + parent != null
154 + ? this._elementToElementTreeMetadata.get(parent)
155 + : null;
156 + }
157 + }
158 + }
159 +
160 + // Scan for additions
161 + for (let i = 0; i < numChildren; i++) {
162 + const childID = children[i];
163 + if (!prevChildren.includes(childID)) {
164 + const child = this._idToElement.get(childID);
165 + const { depth } = this._elementToElementTreeMetadata.get(element);
166 + const weight = this._crawlForTreeMetadata(
167 + childID,
168 + depth + 1
169 + );
170 +
171 + this.numElements += weight;
172 +
173 + let current = element;
174 + while (current !== null) {
175 + const datum = this._elementToElementTreeMetadata.get(current);
176 + datum.weight += weight;
177 +
178 + const parent = this._idToElement.get(datum.parentID);
179 + current =
180 + parent != null
181 + ? this._elementToElementTreeMetadata.get(parent)
182 + : null;
183 + }
184 + }
185 + }
186 + }
187 +
188 +__printTree() {
189 + let i = 0;
190 + this.roots.forEach(rootID => {
191 + const root = this._idToElement.get(rootID);
192 + const { weight } = this._elementToElementTreeMetadata.get(root);
193 + for (let j = i; j < i + weight; j++) {
194 + const element = this.getElementAtIndex(j)
195 + const { depth } = this._elementToElementTreeMetadata.get(element);
196 +
197 + console.log(' '.repeat(depth) + element.displayName);
198 + }
199 + i += weight;
200 + });
201 +}
202 +
203 onBridgeMount = (element: Element) => {
204 const { id } = element;
205 debug('onBridgeMount()', element);
@@ -54,23 +216,47 @@ class Store extends EventEmitter {
216 debug('onBridgeRoot()', id);
217 if (!this.roots.includes(id)) {
218 this.roots = this.roots.concat(id);
219 +
220 + // Generate tree metadata used for windowing.
221 + this.numElements += this._crawlForTreeMetadata(id);
222 +
223 this.emit('roots');
224 }
225 };
226
227 + onBridgeRootCommitted = (rootID: string) => {
228 + this._pendingDeletions.forEach(id => {
229 + this._idToElement.delete(id);
230 +
231 + if (this._idToParentID.has(id)) {
232 + this._idToParentID.delete(id);
233 + }
234 + });
235 + this._pendingDeletions.clear();
236 +
237 + debug('onBridgeRootCommitted()', rootID);
238 + this.emit('rootCommitted', rootID);
239 +
240 +this.__printTree();
241 + };
242 +
243 + // TODO: Unmounting removes id-to-element before crawling, which breaks it.
244 + // Should I just ditch the idea of a WeakMap in favor of an explicit it-to-metadata mapping like with parents?
245 onBridgeUnmounted = (id: string) => {
246 debug('onBridgeUnmounted()', id);
63 - this._idToElement.delete(id);
64 -
65 - if (this._idToParentID.has(id)) {
66 - this._idToParentID.delete(id);
67 - }
247 + this._pendingDeletions.add(id);
248
249 const index = this.roots.indexOf(id);
250 if (index >= 0) {
251 this.roots = this.roots
252 .slice(0, index)
253 .concat(this.roots.slice(index + 1));
254 +
255 + const root = this._idToElement.get(id);
256 + const {weight} = this._elementToElementTreeMetadata.get(root);
257 +
258 + this.numElements -= weight;
259 +
260 this.emit('roots');
261 }
262 };
@@ -78,7 +264,18 @@ class Store extends EventEmitter {
264 onBridgeUpdated = (element: Element) => {
265 const { id } = element;
266 debug('onBridgeUpdated()', element);
267 +
268 + const prevElement = ((this._idToElement.get(id): any): Element);
269 + const prevElementTreeMetadata = ((this._elementToElementTreeMetadata.get(prevElement): any): ElementTreeMetadata);
270 +
271 this._idToElement.set(id, element);
272 + this._elementToElementTreeMetadata.set(
273 + element,
274 + prevElementTreeMetadata
275 + );
276 +
277 + // Update tree metadata used for windowing.
278 + this._updateElementTreeMetadata(prevElement, element);
279
280 this.emit(id);
281 };
src/devtools/types.js
+13
@@ -1,5 +1,7 @@
1 // @flow
2
3 +import typeof Store from './Store';
4 +
5 export const ElementTypeClassOrFunction = 1;
6 export const ElementTypeContext = 2;
7 export const ElementTypeForwardRef = 3;
@@ -20,6 +22,12 @@ export type Element = {|
22 children: Array<string>,
23 |};
24
25 +export type ElementTreeMetadata = {|
26 + depth: number,
27 + rootID: string,
28 + weight: number,
29 +|};
30 +
31 export type InspectedElement = {|
32 id: string,
33 context: Object | null,
@@ -29,3 +37,8 @@ export type InspectedElement = {|
37 canEditProps: boolean,
38 source: Object,
39 |};
40 +
41 +export type TreeContext = {|
42 + size: number,
43 + store: Store,
44 +|};
\ No newline at end of file
src/devtools/views/Element.js
+28 -29
@@ -1,19 +1,18 @@
1 // @flow
2
3 import React, { Fragment, useContext } from 'react';
4 -import { useElement } from './hooks';
5 -import { StoreContext } from './contexts';
4 +import { TreeContext } from './contexts';
5
6 import styles from './Element.css';
7
8 type Props = {|
10 - depth: number,
11 - id: string,
9 + index: number,
10 + style: Object,
11 |};
12
14 -export default function Element({ depth, id }: Props) {
15 - const store = useContext(StoreContext);
16 - const element = useElement(store, id);
13 +export default function Element({ index, style }: Props) {
14 + const {store} = useContext(TreeContext);
15 + const element = store.getElementAtIndex(index);
16
17 // DevTools are rendered in concurrent mode.
18 // 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) {
22 return null;
23 }
24
25 + const elementTreeMetadata = store.getTreeMetadataForElement(element);
26 +
27 const { children, displayName, key } = element;
28 + const { depth } = elementTreeMetadata;
29
30 // TODO: Add state for toggling element open/close
31
32 return (
31 - <Fragment>
32 - <div
33 - className={styles.Element}
34 - style={{ paddingLeft: `${1 + depth}rem` }}
35 - >
36 - {children.length > 0 && <span className={styles.ArrowOpen} />}
37 -
38 - <span className={styles.Component}>
39 - {displayName}
40 - {key && (
41 - <Fragment>
42 - &nbsp;<span className={styles.AttributeName}>key</span>=
43 - <span className={styles.AttributeValue}>"{key}"</span>
44 - </Fragment>
45 - )}
46 - </span>
47 - </div>
48 -
49 - {children.map(childID => (
50 - <Element key={childID} depth={depth + 1} id={childID} />
51 - ))}
52 - </Fragment>
33 + <div
34 + className={styles.Element}
35 + style={{
36 + ...style,
37 + paddingLeft: `${1 + depth}rem`
38 + }}
39 + >
40 + {children.length > 0 && <span className={styles.ArrowOpen} />}
41 +
42 + <span className={styles.Component}>
43 + {displayName}
44 + {key && (
45 + <Fragment>
46 + &nbsp;<span className={styles.AttributeName}>key</span>=
47 + <span className={styles.AttributeValue}>"{key}"</span>
48 + </Fragment>
49 + )}
50 + </span>
51 + </div>
52 );
53 }
src/devtools/views/Elements.js
+25 -5
@@ -1,9 +1,9 @@
1 // @flow
2
3 -import React, { useMemo } from 'react';
3 +import React, { useLayoutEffect, useMemo, useState } from 'react';
4 import Store from '../store';
5 import Tree from './Tree';
6 -import { StoreContext } from './contexts';
6 +import { StoreContext, TreeContext } from './contexts';
7 import styles from './Elements.css';
8
9 import type { Bridge } from '../../types';
@@ -17,11 +17,31 @@ export type Props = {|
17 export default function Elements({ bridge, browserName, themeName }: Props) {
18 const store = useMemo(() => new Store(bridge), []);
19
20 + const [treeContext, setTreeContext] = useState(({
21 + size: store.numElements,
22 + store,
23 + }));
24 +
25 + useLayoutEffect(() => {
26 + const handler = () => {
27 + setTreeContext({
28 + size: store.numElements,
29 + store,
30 + });
31 + };
32 +
33 + store.addListener('rootCommitted', handler);
34 +
35 + return () => store.removeListener('rootCommitted', handler);
36 + }, [store]);
37 +
38 return (
39 <StoreContext.Provider value={store}>
22 - <div className={styles.Elements}>
23 - <Tree />
24 - </div>
40 + <TreeContext.Provider value={treeContext}>
41 + <div className={styles.Elements}>
42 + <Tree />
43 + </div>
44 + </TreeContext.Provider>
45 </StoreContext.Provider>
46 );
47 }
src/devtools/views/Tree.js
+21 -33
@@ -1,44 +1,32 @@
1 // @flow
2
3 -import React, { useContext } from 'react';
3 +import React, { useContext, useMemo } from 'react';
4 +import AutoSizer from 'react-virtualized-auto-sizer';
5 +import { FixedSizeList } from 'react-window';
6 import Element from './Element';
5 -import { StoreContext } from './contexts';
6 -import { useElement, useRoots } from './hooks';
7 +import { TreeContext } from './contexts';
8
9 import styles from './Tree.css';
10
10 -type TreeProps = {||};
11 +type Props = {||};
12
12 -export default function Tree(props: TreeProps) {
13 - const store = useContext(StoreContext);
14 - const roots = useRoots(store);
13 +export default function Tree(props: Props) {
14 + const treeContext = useContext(TreeContext);
15
16 return (
17 - <div className={styles.Tree}>
18 - {roots.map(id => (
19 - <Root key={id} id={id} />
20 - ))}
21 - </div>
17 + <AutoSizer>
18 + {({ height, width }) => (
19 + <FixedSizeList
20 + className={styles.Tree}
21 + height={height}
22 + itemCount={treeContext.size}
23 + itemData={treeContext}
24 + itemSize={20}
25 + width={width}
26 + >
27 + {Element}
28 + </FixedSizeList>
29 + )}
30 + </AutoSizer>
31 );
32 }
24 -
25 -type RootProps = {|
26 - id: string,
27 -|};
28 -
29 -function Root({ id }: RootProps) {
30 - const store = useContext(StoreContext);
31 - const element = useElement(store, id);
32 -
33 - // DevTools are rendered in concurrent mode.
34 - // It's possible the store has updated since the commit that triggered this render.
35 - // So we need to guard against an undefined element.
36 - // TODO: Handle this by switching to a Suspense based approach.
37 - if (element == null) {
38 - return null;
39 - }
40 -
41 - return element.children.map(childID => (
42 - <Element key={childID} depth={0} id={childID} />
43 - ));
44 -}
src/devtools/views/contexts.js
+3
@@ -2,7 +2,10 @@
2
3 import { createContext } from 'react';
4
5 +import type { TreeMetadata } from '../types';
6 +
7 import Store from '../store';
8
9 export const RootsContext = createContext<Array<string>>([]);
10 export const StoreContext = createContext<Store>(((null: any): Store));
11 +export const TreeContext = createContext<TreeMetadata>(((null: any): TreeMetadata));
\ No newline at end of file
src/devtools/views/hooks.js
+5 -4
@@ -2,7 +2,7 @@
2
3 import { useLayoutEffect, useState } from 'react';
4
5 -import type { Element } from '../types';
5 +import type { Element, ElementTreeMetadata } from '../types';
6 import Store from '../store';
7
8 // TODO useEffect has a bug where sometimes subscriptions don't get cleaned up correctly.
@@ -10,16 +10,17 @@ import Store from '../store';
10 // As a temporary work around, switch back to layout effect.
11
12 export function useElement(store: Store, id: string): ?Element {
13 - const [element, setElement] = useState<?Element>(store.getElement(id));
13 + const [element, setElement] = useState<?Element>(store.getElementByID(id));
14
15 useLayoutEffect(() => {
16 - const handler = () => setElement(((store.getElement(id): any): Element));
16 + const handler = () =>
17 + setElement(((store.getElementByID(id): any): Element));
18
19 // Listen for changes to the element.
20 store.addListener(id, handler);
21
22 // Check for changes that may have happened between render and mount.
22 - const newElement = store.getElement(id);
23 + const newElement = store.getElementByID(id);
24 if (element !== newElement) {
25 setElement(newElement);
26 }
yarn.lock
+18 -3
@@ -674,6 +674,13 @@
674 "@babel/plugin-transform-react-jsx-self" "^7.0.0"
675 "@babel/plugin-transform-react-jsx-source" "^7.0.0"
676
677 +"@babel/runtime@^7.0.0":
678 + version "7.3.1"
679 + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.1.tgz#574b03e8e8a9898eaf4a872a92ea20b7846f6f2a"
680 + integrity sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA==
681 + dependencies:
682 + regenerator-runtime "^0.12.0"
683 +
684 "@babel/template@^7.1.0", "@babel/template@^7.1.2":
685 version "7.1.2"
686 resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644"
@@ -8413,11 +8420,14 @@ react-portal@^3.1.0:
8420 react-virtualized-auto-sizer@^1.0.2:
8421 version "1.0.2"
8422 resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
8423 + integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
8424
8417 -react-window@^1.1.1:
8418 - version "1.1.1"
8419 - resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.1.1.tgz#8a0cf488c9db19425fb804f118f5aac6227d7fd2"
8425 +react-window@^1.5.0:
8426 + version "1.5.0"
8427 + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.5.0.tgz#3e56b720b97666bce38a9e932bdd238d56e258f1"
8428 + integrity sha512-55WeZKjMNF5JdCuKghc/H65DBecoeGgH8MOX3CgT7BJ66xb4ITRuXPUlz0qU6r50wetdF/oLhorYBRvKD4Z1IQ==
8429 dependencies:
8430 + "@babel/runtime" "^7.0.0"
8431 memoize-one "^3.1.1"
8432
8433 react@^16.8.0-alpha.1:
@@ -8586,6 +8596,11 @@ regenerator-runtime@^0.11.0:
8596 version "0.11.1"
8597 resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
8598
8599 +regenerator-runtime@^0.12.0:
8600 + version "0.12.1"
8601 + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
8602 + integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
8603 +
8604 regenerator-runtime@^0.9.5:
8605 version "0.9.6"
8606 resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz#d33eb95d0d2001a4be39659707c51b0cb71ce029"