Small refinements to Store: read-only Elements in map, added revision to guard against tearing
Brian Vaughn committed
Feb 8, 2019 at 15:37 UTC
41514d67f0be930d0aefe4b1277f3b7579358e6a
3 files changed
+51
-24
src/devtools/store.js
+29
-8
@@ -28,14 +28,18 @@ const debug = (methodName, ...args) => {
28
* ContextProviders can subscribe to the Store for specific things they want to provide.
29
*/
30
export default class Store extends EventEmitter {
31
- // TODO Should items in this map be read-only/immutable for easier props comparison?
32
- // We currently mutate "children" and "weight" props.
31
+ // Map of ID to Element.
32
+ // Elements are read-only so they wokr well with memoization.
33
_idToElement: Map<number, Element> = new Map();
34
35
// Total number of visible elements (within all roots).
36
// Used for windowing purposes.
37
_numElements: number = 0;
38
39
+ // Incremented each time the store is mutated.
40
+ // This enables a passive effect to detect a mutation between render and commit phase.
41
+ _revision: number = 0;
42
+
43
// This Array must be treated as immutable!
44
// Passive effects will check it for changes between render and mount.
45
_roots: $ReadOnlyArray<number> = [];
@@ -55,6 +59,10 @@ export default class Store extends EventEmitter {
59
return this._numElements;
60
}
61
62
+ get revision(): number {
63
+ return this._revision;
64
+ }
65
+
66
get roots(): $ReadOnlyArray<number> {
67
return this._roots;
68
}
@@ -275,7 +283,11 @@ export default class Store extends EventEmitter {
283
// Maybe in the future we'll revisit this.
284
} else {
285
parentElement = ((this._idToElement.get(parentID): any): Element);
278
- parentElement.children = parentElement.children.concat(id);
286
+
287
+ this._idToElement.set(parentID, {
288
+ ...parentElement,
289
+ children: parentElement.children.concat(id),
290
+ });
291
292
const element: Element = {
293
children: [],
@@ -319,9 +331,12 @@ export default class Store extends EventEmitter {
331
this._roots = this._roots.filter(rootID => rootID !== id);
332
this._rootIDToRendererID.delete(id);
333
} else {
322
- parentElement.children = parentElement.children.filter(
323
- childID => childID !== id
324
- );
334
+ this._idToElement.set(parentID, {
335
+ ...parentElement,
336
+ children: parentElement.children.filter(
337
+ childID => childID !== id
338
+ ),
339
+ });
340
}
341
342
// Track removed items so search results can be updated
@@ -343,7 +358,6 @@ export default class Store extends EventEmitter {
358
debug('Re-order', `fiber ${id} children ${children.join(',')}`);
359
360
element = ((this._idToElement.get(id): any): Element);
346
- element.children = Array.from(children);
361
362
const prevWeight = element.weight;
363
let childWeight = 0;
@@ -353,7 +367,12 @@ export default class Store extends EventEmitter {
367
childWeight += child.weight;
368
});
369
356
- element.weight = childWeight + 1;
370
+ this._idToElement.set(id, {
371
+ ...element,
372
+ children: Array.from(children),
373
+ weight: childWeight + 1,
374
+ });
375
+
376
weightDelta = childWeight + 1 - prevWeight;
377
break;
378
default:
@@ -370,6 +389,8 @@ export default class Store extends EventEmitter {
389
}
390
}
391
392
+ this._revision++;
393
+
394
if (haveRootsChanged) {
395
this.emit('roots');
396
}
src/devtools/types.js
+2
-2
@@ -18,7 +18,7 @@ export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
18
// Some of its information (e.g. id, type, displayName) come from the backend.
19
// Other bits (e.g. weight and depth) are computed on the frontend for windowing and display purposes.
20
// Elements are udpated on a push basis– meaning the backend pushes updates to the frontend when needed.
21
-export type Element = {|
21
+export type Element = $ReadOnly<{|
22
id: number,
23
parentID: number,
24
children: Array<number>,
@@ -37,7 +37,7 @@ export type Element = {|
37
// This property is used to quickly determine the total number of Elements,
38
// and the Element at any given index (for windowing purposes).
39
weight: number,
40
-|};
40
+|}>;
41
42
export type Owner = {|
43
displayName: string,
src/devtools/views/TreeContext.js
+20
-14
@@ -21,7 +21,7 @@ import React, {
21
createContext,
22
useCallback,
23
useContext,
24
- useLayoutEffect,
24
+ useEffect,
25
useMemo,
26
useReducer,
27
} from 'react';
@@ -112,11 +112,11 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
112
numElements = store.numElements;
113
114
// If the currently-selected Element has been removed from the tree, update selection state.
115
- if (selectedElementID !== null) {
116
- const removedElementIDs = ((payload: any): Array<Uint32Array>)[1];
117
- if (removedElementIDs.includes(((selectedElementID: any): number))) {
118
- selectedElementIndex = null;
119
- }
115
+ if (
116
+ selectedElementID !== null &&
117
+ store.getElementByID(selectedElementID) === null
118
+ ) {
119
+ selectedElementIndex = null;
120
}
121
break;
122
case 'SELECT_ELEMENT_AT_INDEX':
@@ -330,12 +330,9 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
330
switch (type) {
331
case 'HANDLE_STORE_MUTATION':
332
if (ownerStack.length > 0) {
333
- // eslint-disable-next-line no-unused-vars
334
- const [_, removedElementIDs] = ((payload: any): Array<Uint32Array>);
335
-
333
let indexOfRemovedItem = -1;
334
for (let i = 0; i < ownerStack.length; i++) {
338
- if (removedElementIDs.includes(ownerStack[i])) {
335
+ if (store.getElementByID(ownerStack[i]) === null) {
336
indexOfRemovedItem = i;
337
break;
338
}
@@ -467,6 +464,7 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
464
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
465
function TreeContextController({ children }: {| children: React$Node |}) {
466
const store = useContext(StoreContext);
467
+ const initialRevision = useMemo(() => store.revision, [store]);
468
469
// This reducer is created inline because it needs access to the Store.
470
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
@@ -592,7 +590,7 @@ function TreeContextController({ children }: {| children: React$Node |}) {
590
);
591
592
// Mutations to the underlying tree may impact this context (e.g. search results, selection state).
595
- useLayoutEffect(() => {
593
+ useEffect(() => {
594
const handleStoreMutated = ([
595
addedElementIDs,
596
removedElementIDs,
@@ -603,13 +601,21 @@ function TreeContextController({ children }: {| children: React$Node |}) {
601
});
602
};
603
606
- // TODO Even though we're using layout effect, concurrent rendering may cause us to miss a mutation.
607
- // Should the store expose some sort of version number that we could check after mounting?
604
+ // Since this is a passive effect, the tree may have been mutated before our initial subscription.
605
+ if (store.revision !== initialRevision) {
606
+ // At the moment, we can treat this as a mutation.
607
+ // We don't know which Elements were newly added/removed, but that should be okay in this case.
608
+ // It would only impact the search state, which is unlikely to exist yet at this point.
609
+ dispatch({
610
+ type: 'HANDLE_STORE_MUTATION',
611
+ payload: [new Uint32Array(0), new Uint32Array(0)],
612
+ });
613
+ }
614
615
store.addListener('mutated', handleStoreMutated);
616
617
return () => store.removeListener('mutated', handleStoreMutated);
612
- }, [state, store]);
618
+ }, [store]);
619
620
return <TreeContext.Provider value={value}>{children}</TreeContext.Provider>;
621
}