diff --git a/package.json b/package.json index 043c75ee8c..351d9da59b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/agent.js b/src/backend/agent.js index 9cfba3fd0e..9c75b216a6 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -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 }) => { diff --git a/src/devtools/store.js b/src/devtools/store.js index 04378813c8..b168fbbf9c 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -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 = new Map(); _idToParentID: Map = new Map(); + _pendingDeletions: Set = 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); }; diff --git a/src/devtools/types.js b/src/devtools/types.js index a8ac7155aa..c8cc329b41 100644 --- a/src/devtools/types.js +++ b/src/devtools/types.js @@ -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, |}; +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, +|}; \ No newline at end of file diff --git a/src/devtools/views/Element.js b/src/devtools/views/Element.js index e4aa3f8116..09cc97c902 100644 --- a/src/devtools/views/Element.js +++ b/src/devtools/views/Element.js @@ -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 ( - -
- {children.length > 0 && } +
+ {children.length > 0 && } - - {displayName} - {key && ( - -  key= - "{key}" - - )} - -
- - {children.map(childID => ( - - ))} - + + {displayName} + {key && ( + +  key= + "{key}" + + )} + +
); } diff --git a/src/devtools/views/Elements.js b/src/devtools/views/Elements.js index 9d571ebb5f..6f6619661d 100644 --- a/src/devtools/views/Elements.js +++ b/src/devtools/views/Elements.js @@ -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 ( -
- -
+ +
+ +
+
); } diff --git a/src/devtools/views/Tree.js b/src/devtools/views/Tree.js index b94c0d6994..1ccd23d768 100644 --- a/src/devtools/views/Tree.js +++ b/src/devtools/views/Tree.js @@ -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 ( -
- {roots.map(id => ( - - ))} -
+ + {({ height, width }) => ( + + {Element} + + )} + ); } - -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 => ( - - )); -} diff --git a/src/devtools/views/contexts.js b/src/devtools/views/contexts.js index d5a76c5f07..f7428d3fc6 100644 --- a/src/devtools/views/contexts.js +++ b/src/devtools/views/contexts.js @@ -2,7 +2,10 @@ import { createContext } from 'react'; +import type { TreeMetadata } from '../types'; + import Store from '../store'; export const RootsContext = createContext>([]); export const StoreContext = createContext(((null: any): Store)); +export const TreeContext = createContext(((null: any): TreeMetadata)); \ No newline at end of file diff --git a/src/devtools/views/hooks.js b/src/devtools/views/hooks.js index 6d589fba0c..9576528afa 100644 --- a/src/devtools/views/hooks.js +++ b/src/devtools/views/hooks.js @@ -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(store.getElement(id)); + const [element, setElement] = useState(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); } diff --git a/yarn.lock b/yarn.lock index 494ec53552..188fbf67a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"