@samitouri / QOS-React-2 / commits / 41514d67f0

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 }