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
- <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
+ <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"