diff --git a/package.json b/package.json index 351d9da59b..81f577f341 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.5.0", + "react-window": "^1.5.1", "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 9c75b216a6..89f13dfe51 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -1,44 +1,28 @@ // @flow -import nullthrows from 'nullthrows'; import EventEmitter from 'events'; -import { guid } from './utils'; -import { ElementTypeOtherOrUnknown } from 'src/devtools/types'; -import type { - Fiber, - RendererData, - RendererID, - RendererInterface, -} from './types'; +import type { RendererID, RendererInterface } from './types'; import type { Bridge } from '../types'; -import type { Element } from 'src/devtools/types'; const debug = (methodName, ...args) => { - console.log(`%cAgent %c${methodName}`, 'color: blue; font-weight: bold;', 'font-weight: bold;', ...args); + console.log( + `%cAgent %c${methodName}`, + 'color: purple; font-weight: bold;', + 'font-weight: bold;', + ...args + ); }; -const THROTTLE_BY_MS = 350; - export default class Agent extends EventEmitter { - _fiberToID: WeakMap = new WeakMap(); - _idToElement: Map = new Map(); - _idToFiber: Map = new Map(); - _idToRendererData: Map = new Map(); - _idToRendererID: Map = new Map(); + _bridge: Bridge = ((null: any): Bridge); _rendererInterfaces: { [key: RendererID]: RendererInterface } = {}; - _roots: Set = new Set(); addBridge(bridge: Bridge) { + this._bridge = bridge; + // TODO Listen to bridge for things like selection. // 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)); - // TODO Add other methods for e.g. profiling. } setRendererInterface( @@ -48,148 +32,23 @@ export default class Agent extends EventEmitter { this._rendererInterfaces[rendererID] = rendererInterface; } - _getId(fiber: Fiber): string { - if (typeof fiber !== 'object' || !fiber) { - return fiber; - } - if (!this._fiberToID.has(fiber)) { - this._fiberToID.set(fiber, guid()); - this._idToFiber.set(nullthrows(this._fiberToID.get(fiber)), fiber); - } - return nullthrows(this._fiberToID.get(fiber)); - } - - _crawl(parent: Element, id: string): void { - const data: RendererData = ((this._idToRendererData.get( - id - ): any): RendererData); - if (data.type === ElementTypeOtherOrUnknown) { - data.children.forEach(childFiber => { - if (childFiber !== null) { - this._crawl(parent, this._getId(childFiber)); - } - }); - } else { - parent.children.push(id); - - this._createOrUpdateElement(id, data); - } - } - - _createOrUpdateElement(id: string, data: RendererData): void { - const prevElement: ?Element = this._idToElement.get(id); - const nextElement: Element = { - id, - key: data.key, - displayName: data.displayName, - children: [], - type: data.type, - }; - - this._idToElement.set(id, nextElement); - - data.children.forEach(childFiber => { - if (childFiber !== null) { - this._crawl(nextElement, this._getId(childFiber)); - } - }); - - if (prevElement == null) { - debug('emit("mount")', id, nextElement); - this.emit('mount', nextElement); - } else if (!areElementsEqual(prevElement, nextElement)) { - debug('emit("update")', id, nextElement); - this.emit('update', nextElement); - } - } - - onHookMount = ({ - data, - fiber, - renderer, - }: { - data: RendererData, - fiber: Fiber, - renderer: RendererID, - }) => { - const id = this._getId(fiber); - - this._idToRendererData.set(id, data); - this._idToRendererID.set(id, renderer); + onHookDisplayNames = (displayNames: Map) => { + debug('onHookDisplayNames', displayNames); + this._bridge.send('displayNames', displayNames); }; - onHookRootCommitted = ({ - data, - fiber, - renderer, - }: { - data: RendererData, - fiber: Fiber, - renderer: RendererID, - }) => { - const id = this._getId(fiber); - - // TODO: Can we use the effects list on update for a faster path? - // Mounts (Placements) and unmounts (Deletions) are on the child; need to update parent children in that case. - this._createOrUpdateElement(id, data); - - if (!this._roots.has(id)) { - this._roots.add(id); - debug('emit("root")', id); - this.emit('root', id); - } - - this.emit('rootCommitted', id); + onHookKeys = (keys: Map) => { + debug('onHookKeys', keys); + this._bridge.send('keys', keys); }; - onHookUnmount = ({ fiber }: { fiber: Fiber }) => { - const id = this._getId(fiber); - - if (this._roots.has(id)) { - this._roots.delete(id); - this.emit('rootUnmounted', id); - } - - if (this._idToElement.has(id)) { - this._idToElement.delete(id); - debug('emit("unmount")', id); - this.emit('unmount', id); - } - - this._fiberToID.delete(fiber); - this._idToRendererData.delete(id); - this._idToRendererID.delete(id); + onHookOperations = (operations: Uint32Array) => { + debug('onHookOperations', operations); + this._bridge.send('operations', operations); }; - onHookUpdate = ({ data, fiber }: { data: RendererData, fiber: Fiber }) => { - const id = this._getId(fiber); - - this._idToRendererData.set(id, data); + onHookRootCommitted = (rootID: string) => { + debug('onHookRootCommitted', rootID); + this._bridge.send('rootCommitted', rootID); }; } - -function areElementsEqual( - prevElement: ?Element, - nextElement: ?Element -): boolean { - if (!prevElement || !nextElement) { - return false; - } - - if ( - prevElement.key !== nextElement.key || - prevElement.displayName !== nextElement.displayName || - prevElement.children.length !== nextElement.children.length || - prevElement.type !== nextElement.type - ) { - return false; - } - - for (let i = 0; i < prevElement.children.length; i++) { - if (prevElement.children[i] !== nextElement.children[i]) { - return false; - } - } - - return true; -} diff --git a/src/backend/index.js b/src/backend/index.js index c7176b83f6..a634b9c8aa 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -12,10 +12,8 @@ export function initBackend(hook: Hook, agent: Agent): void { rendererInterface.walkTree(); }), + hook.sub('operations', agent.onHookOperations), hook.sub('rootCommitted', agent.onHookRootCommitted), - hook.sub('mount', agent.onHookMount), - hook.sub('update', agent.onHookUpdate), - hook.sub('unmount', agent.onHookUnmount), // TODO Add additional subscriptions required for profiling mode ]; diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 3b29dc1274..1d1392f7a8 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -8,21 +8,25 @@ import { ElementTypeMemo, ElementTypeOtherOrUnknown, ElementTypeProfiler, + ElementTypeRoot, ElementTypeSuspense, } from 'src/devtools/types'; +import { utfEncodeString } from '../utils'; import { getDisplayName } from './utils'; +import { + TREE_OPERATION_ADD, + TREE_OPERATION_REMOVE, + TREE_OPERATION_RESET_CHILDREN, +} from '../constants'; import type { Fiber, Hook, ReactRenderer, - RendererData, + FiberData, RendererInterface, } from './types'; -// TODO: If we're profiling, process update -// TODO: Throttle process update to app tree - function getInternalReactConstants(version) { const ReactSymbols = { CONCURRENT_MODE_NUMBER: 0xeacf, @@ -184,10 +188,61 @@ export function attach( const primaryFibers: WeakSet = new WeakSet(); - // TODO: we might want to change the data structure - // once we no longer suppport Stack versions of `getData`. + // Keep this function in sync with getDataForFiber() + function shouldFilterFiber(fiber: Fiber): boolean { + const { type, tag } = fiber; + + switch (tag) { + case ClassComponent: + case FunctionComponent: + case IncompleteClassComponent: + case IndeterminateComponent: + case ForwardRef: + case HostRoot: + case MemoComponent: + case SimpleMemoComponent: + return false; + case HostPortal: + case HostComponent: + case HostText: + case Fragment: + return true; + default: + const symbolOrNumber = + typeof type === 'object' && type !== null ? type.$$typeof : type; + + const switchValue = + // $FlowFixMe facebook/flow/issues/2362 + typeof symbolOrNumber === 'symbol' + ? symbolOrNumber.toString() + : symbolOrNumber; + + switch (switchValue) { + case CONCURRENT_MODE_NUMBER: + case CONCURRENT_MODE_SYMBOL_STRING: + case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: + case STRICT_MODE_NUMBER: + case STRICT_MODE_SYMBOL_STRING: + return true; + case CONTEXT_PROVIDER_NUMBER: + case CONTEXT_PROVIDER_SYMBOL_STRING: + case CONTEXT_CONSUMER_NUMBER: + case CONTEXT_CONSUMER_SYMBOL_STRING: + case SUSPENSE_NUMBER: + case SUSPENSE_SYMBOL_STRING: + case DEPRECATED_PLACEHOLDER_SYMBOL_STRING: + case PROFILER_NUMBER: + case PROFILER_SYMBOL_STRING: + return false; + default: + return false; + } + } + } + + // TODO: we might want to change the data structure once we no longer suppport Stack versions of `getData`. // TODO: Keep in sync with getElementType() - function getRendererDataFromFiber(fiber: Fiber): RendererData { + function getDataForFiber(fiber: Fiber): FiberData { const { elementType, type, key, tag } = fiber; // This is to support lazy components with a Promise as the type. @@ -199,11 +254,7 @@ export function attach( } } - // Suspense has some special behavior when timed out that we have to handle. - // see https://github.com/facebook/react/pull/13823 - let isTimedOutSuspense: boolean = false; - - let rendererData: RendererData = ((null: any): RendererData); + let fiberData: FiberData = ((null: any): FiberData); let displayName: string = ((null: any): string); let resolvedContext: any = null; @@ -212,8 +263,7 @@ export function attach( case FunctionComponent: case IncompleteClassComponent: case IndeterminateComponent: - rendererData = { - children: [], + fiberData = { displayName: getDisplayName(resolvedType), key, type: ElementTypeClassOrFunction, @@ -224,26 +274,28 @@ export function attach( displayName = resolvedType.displayName || (functionName !== '' ? `ForwardRef(${functionName})` : 'ForwardRef'); - rendererData = { - children: [], + + fiberData = { displayName, key, type: ElementTypeForwardRef, }; break; case HostRoot: + return { + displayName: null, + key: null, + type: ElementTypeRoot, + }; case HostPortal: case HostComponent: case HostText: case Fragment: - // Not displayed in Elements tree - rendererData = { - children: [], + return { displayName: null, - key, + key: null, type: ElementTypeOtherOrUnknown, }; - break; case MemoComponent: case SimpleMemoComponent: if (elementType.displayName) { @@ -252,8 +304,7 @@ export function attach( displayName = type.displayName || type.name; displayName = displayName ? `Memo(${displayName})` : 'Memo'; } - rendererData = { - children: [], + fiberData = { displayName, key, type: ElementTypeMemo, @@ -273,14 +324,11 @@ export function attach( case CONCURRENT_MODE_NUMBER: case CONCURRENT_MODE_SYMBOL_STRING: case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: - // Not displayed in Elements tree - rendererData = { - children: [], + return { displayName: null, - key, + key: null, type: ElementTypeOtherOrUnknown, }; - break; case CONTEXT_PROVIDER_NUMBER: case CONTEXT_PROVIDER_SYMBOL_STRING: // 16.3.0 exposed the context object as "context" @@ -289,8 +337,7 @@ export function attach( displayName = `${resolvedContext.displayName || 'Context'}.Provider`; - rendererData = { - children: [], + fiberData = { displayName, key, type: ElementTypeContext, @@ -307,8 +354,7 @@ export function attach( displayName = `${resolvedContext.displayName || 'Context'}.Consumer`; - rendererData = { - children: [], + fiberData = { displayName, key, type: ElementTypeContext, @@ -316,8 +362,7 @@ export function attach( break; case STRICT_MODE_NUMBER: case STRICT_MODE_SYMBOL_STRING: - rendererData = { - children: [], + fiberData = { displayName: null, key, type: ElementTypeOtherOrUnknown, @@ -326,11 +371,7 @@ export function attach( case SUSPENSE_NUMBER: case SUSPENSE_SYMBOL_STRING: case DEPRECATED_PLACEHOLDER_SYMBOL_STRING: - // Suspense components only have a non-null memoizedState if they're timed-out. - isTimedOutSuspense = fiber.memoizedState !== null; - - rendererData = { - children: [], + fiberData = { displayName: 'Suspense', key, type: ElementTypeSuspense, @@ -338,8 +379,7 @@ export function attach( break; case PROFILER_NUMBER: case PROFILER_SYMBOL_STRING: - rendererData = { - children: [], + fiberData = { displayName: `Profiler(${fiber.memoizedProps.id})`, key, type: ElementTypeProfiler, @@ -348,8 +388,7 @@ export function attach( default: // Unknown element type. // This may mean a new element type that has not yet been added to DevTools. - rendererData = { - children: [], + fiberData = { displayName: null, key, type: ElementTypeOtherOrUnknown, @@ -359,30 +398,7 @@ export function attach( break; } - const { children } = rendererData; - if (isTimedOutSuspense) { - // The behavior of timed-out Suspense trees is unique. - // Rather than unmount the timed out content (and possibly lose important state), - // React re-parents this content within a hidden Fragment while the fallback is showing. - // This behavior doesn't need to be observable in the DevTools though. - // It might even result in a bad user experience for e.g. node selection in the Elements panel. - // The easiest fix is to strip out the intermediate Fragment fibers, - // so the Elements panel and Profiler don't need to special case them. - const primaryChildFragment = fiber.child; - const primaryChild = primaryChildFragment.child; - const fallbackChildFragment = primaryChildFragment.sibling; - const fallbackChild = fallbackChildFragment.child; - children.push(primaryChild); - children.push(fallbackChild); - } else { - let child = fiber.child; - while (child) { - children.push(getPrimaryFiber(child)); - child = child.sibling; - } - } - - return rendererData; + return fiberData; } // This is a slightly annoying indirection. @@ -401,6 +417,17 @@ export function attach( return fiber; } + let uidCounter: number = 0; + const fiberToIDMap: WeakMap = new WeakMap(); + + function getFiberID(primaryFiber: Fiber): number { + if (!fiberToIDMap.has(primaryFiber)) { + fiberToIDMap.set(primaryFiber, ++uidCounter); + } + return ((fiberToIDMap.get(primaryFiber): any): number); + } + + // eslint-disable-next-line no-unused-vars function hasDataChanged(prevFiber: Fiber, nextFiber: Fiber): boolean { switch (nextFiber.tag) { case ClassComponent: @@ -425,6 +452,7 @@ export function attach( } } + // eslint-disable-next-line no-unused-vars function haveProfilerTimesChanged( prevFiber: Fiber, nextFiber: Fiber @@ -437,127 +465,176 @@ export function attach( ); } - let pendingEvents = []; + let pendingOperations: Uint32Array = new Uint32Array(0); - // TODO: Do we still need to send events like this? - function flushPendingEvents() { - const events = pendingEvents; - pendingEvents = []; - for (let i = 0; i < events.length; i++) { - const event = events[i]; - hook.emit(event.type, event); + function addOperation( + newAction: Uint32Array, + addToStartOfQueue: boolean = false + ): void { + const oldActions = pendingOperations; + pendingOperations = new Uint32Array(oldActions.length + newAction.length); + if (addToStartOfQueue) { + pendingOperations.set(newAction); + pendingOperations.set(oldActions, newAction.length); + } else { + pendingOperations.set(oldActions); + pendingOperations.set(newAction, oldActions.length); } } - function enqueueMount(fiber) { - pendingEvents.push({ - fiber: getPrimaryFiber(fiber), - data: getRendererDataFromFiber(fiber), - renderer: rendererID, - type: 'mount', - }); + function flushPendingEvents(root: Object): void { + // Let the frontend know about tree operations. + hook.emit('operations', pendingOperations); + pendingOperations = new Uint32Array(0); + // Let the frontend know that we're done working on this root. + // Technically this could be inferred, but it's better to explicitly do this for the case of multi roots. + // Else the frontend would need to traverse the tree to identify which updates corresponded to which roots. + hook.emit('rootCommitted', getFiberID(getPrimaryFiber(root.current))); + } + + function enqueueMount(fiber: Fiber, parentFiber: Fiber | null) { const isRoot = fiber.tag === HostRoot; + const id = getFiberID(getPrimaryFiber(fiber)); + if (isRoot) { - pendingEvents.push({ - fiber: getPrimaryFiber(fiber), - renderer: rendererID, - type: 'root', - }); - } - } + const operation = new Uint32Array(4); + operation[0] = TREE_OPERATION_ADD; + operation[1] = id; + operation[2] = ElementTypeRoot; + operation[3] = 0; // Identifies this fiber as a root + addOperation(operation); + } else { + const { displayName, key, type } = getDataForFiber(fiber); - function enqueueUpdateIfNecessary(fiber, hasChildOrderChanged) { - const data = getRendererDataFromFiber(fiber); + let encodedDisplayName = ((null: any): Uint8Array); + let encodedKey = ((null: any): Uint8Array); - if (!hasChildOrderChanged && !hasDataChanged(fiber.alternate, fiber)) { - // If only timing information has changed, we still need to update the nodes. - // But we can do it in a faster way since we know it's safe to skip the children. - // It's also important to avoid emitting an "update" signal for the node in this case, - // Since that would indicate to the Profiler that it was part of the "commit" when it wasn't. - if (haveProfilerTimesChanged(fiber.alternate, fiber)) { - pendingEvents.push({ - fiber: getPrimaryFiber(fiber), - data, - renderer: rendererID, - type: 'updateProfileTimes', - }); + if (displayName !== null) { + encodedDisplayName = utfEncodeString(displayName); } - return; + + if (key !== null) { + if (typeof key === 'number') { + encodedKey = new Uint8Array(1); + encodedKey[0] = key; + } else { + encodedKey = utfEncodeString(key); + } + } + + const encodedDisplayNameSize = + displayName === null ? 0 : encodedDisplayName.length; + const encodedKeySize = key === null ? 0 : encodedKey.length; + + const operation = new Uint32Array( + 6 + encodedDisplayNameSize + encodedKeySize + ); + operation[0] = TREE_OPERATION_ADD; + operation[1] = id; + operation[2] = type; + operation[3] = getFiberID(getPrimaryFiber(parentFiber)); + operation[4] = encodedDisplayNameSize; + if (displayName !== null) { + operation.set(encodedDisplayName, 5); + } + operation[5 + encodedDisplayNameSize] = encodedKeySize; + if (key !== null) { + operation.set(encodedKey, 5 + encodedDisplayNameSize + 1); + } + addOperation(operation); } - pendingEvents.push({ - fiber: getPrimaryFiber(fiber), - data, - renderer: rendererID, - type: 'update', - }); } function enqueueUnmount(fiber) { const isRoot = fiber.tag === HostRoot; const primaryFiber = getPrimaryFiber(fiber); - const event = { - fiber: primaryFiber, - renderer: rendererID, - type: 'unmount', - }; if (isRoot) { - pendingEvents.push(event); - } else { + const id = getFiberID(getPrimaryFiber(fiber)); + const operation = new Uint32Array(2); + operation[0] = TREE_OPERATION_REMOVE; + operation[1] = id; + addOperation(operation); + } else if (!shouldFilterFiber(fiber)) { // Non-root fibers are deleted during the commit phase. // They are deleted in the child-first order. However // DevTools currently expects deletions to be parent-first. // This is why we unshift deletions rather than push them. - pendingEvents.unshift(event); + const id = getFiberID(getPrimaryFiber(fiber)); + const operation = new Uint32Array(2); + operation[0] = TREE_OPERATION_REMOVE; + operation[1] = id; + addOperation(operation, true); } primaryFibers.delete(primaryFiber); + fiberToIDMap.delete(primaryFiber); } - function markRootCommitted(fiber) { - pendingEvents.push({ - fiber: getPrimaryFiber(fiber), - data: getRendererDataFromFiber(fiber), - renderer: rendererID, - type: 'rootCommitted', - }); - } + function mountFiber(fiber: Fiber, parentFiber: Fiber | null) { + const shouldEnqueueMount = !shouldFilterFiber(fiber); - function mountFiber(fiber) { - // Depth-first. - // Logs mounting of children first, parents later. - let node = fiber; - outer: while (true) { - if (node.child) { - node.child.return = node; - node = node.child; - continue; - } - enqueueMount(node); - if (node === fiber) { - return; - } - if (node.sibling) { - node.sibling.return = node.return; - node = node.sibling; - continue; - } - while (node.return) { - node = node.return; - enqueueMount(node); - if (node === fiber) { - return; - } - if (node.sibling) { - node.sibling.return = node.return; - node = node.sibling; - continue outer; - } - } - return; + if (shouldEnqueueMount) { + enqueueMount(fiber, parentFiber); + } + + if (fiber.child !== null) { + mountFiber(fiber.child, shouldEnqueueMount ? fiber : parentFiber); + } + + if (fiber.sibling) { + mountFiber(fiber.sibling, parentFiber); } } - function updateFiber(nextFiber, prevFiber) { + function enqueueUpdateIfNecessary( + fiber: Fiber, + hasChildOrderChanged: boolean + ) { + // The frontend only really cares about the displayName, key, and children. + // The first two don't really change, so we are only concerned with the order of children here. + // This is trickier than a simple comparison though, since certain types of fibers are filtered. + if (hasChildOrderChanged) { + const nextChildren: Array = []; + + // This is a naive implimentation that shallowly recurses children. + // We might want to revisit this if it proves to be too inefficient. + let child = fiber.child; + while (child !== null) { + findReorderedChildren(child, nextChildren); + child = child.sibling; + } + + const numChildren = nextChildren.length; + const operation = new Uint32Array(3 + numChildren); + operation[0] = TREE_OPERATION_RESET_CHILDREN; + operation[1] = getFiberID(getPrimaryFiber(fiber)); + operation[2] = numChildren; + operation.set(nextChildren, 3); + addOperation(operation); + } + + // TODO (profiling) If we're profiling, also check to see if that data has changed. + } + + function findReorderedChildren(fiber: Fiber, nextChildren: Array) { + if (!shouldFilterFiber(fiber)) { + nextChildren.push(getFiberID(getPrimaryFiber(fiber))); + } else { + let child = fiber.child; + while (child !== null) { + findReorderedChildren(child, nextChildren); + child = child.sibling; + } + } + } + + function updateFiber( + nextFiber: Fiber, + prevFiber: Fiber, + parentFiber: Fiber | null + ) { + const shouldEnqueueUpdate = !shouldFilterFiber(nextFiber); + // Suspense components only have a non-null memoizedState if they're timed-out. const isTimedOutSuspense = nextFiber.tag === ReactTypeOfWork.SuspenseComponent && @@ -574,15 +651,19 @@ export function attach( const primaryChildFragment = nextFiber.child; const fallbackChildFragment = primaryChildFragment.sibling; const fallbackChild = fallbackChildFragment.child; + // The primary, hidden child is never actually updated in this case, // so we can skip any updates to its tree. // We only need to track updates to the Fallback UI for now. if (fallbackChild.alternate) { - updateFiber(fallbackChild, fallbackChild.alternate); + updateFiber(fallbackChild, fallbackChild.alternate, nextFiber); } else { - mountFiber(fallbackChild); + mountFiber(fallbackChild, nextFiber); + } + + if (shouldEnqueueUpdate) { + enqueueUpdateIfNecessary(nextFiber, false); } - enqueueUpdateIfNecessary(nextFiber, false); } else { let hasChildOrderChanged = false; if (nextFiber.child !== prevFiber.child) { @@ -597,7 +678,11 @@ export function attach( // We don't track deletions here because they are reported separately. if (nextChild.alternate) { const prevChild = nextChild.alternate; - updateFiber(nextChild, prevChild); + updateFiber( + nextChild, + prevChild, + shouldEnqueueUpdate ? nextFiber : parentFiber + ); // However we also keep track if the order of the children matches // the previous order. They are always different referentially, but // if the instances line up conceptually we'll want to know that. @@ -605,7 +690,10 @@ export function attach( hasChildOrderChanged = true; } } else { - mountFiber(nextChild); + mountFiber( + nextChild, + shouldEnqueueUpdate ? nextFiber : parentFiber + ); if (!hasChildOrderChanged) { hasChildOrderChanged = true; } @@ -623,7 +711,10 @@ export function attach( hasChildOrderChanged = true; } } - enqueueUpdateIfNecessary(nextFiber, hasChildOrderChanged); + + if (shouldEnqueueUpdate) { + enqueueUpdateIfNecessary(nextFiber, hasChildOrderChanged); + } } } @@ -632,12 +723,11 @@ export function attach( } function walkTree() { + // Hydrate all the roots for the first time. hook.getFiberRoots(rendererID).forEach(root => { - // Hydrate all the roots for the first time. - mountFiber(root.current); - markRootCommitted(root.current); + mountFiber(root.current, null); + flushPendingEvents(root); }); - flushPendingEvents(); } function handleCommitFiberUnmount(fiber) { @@ -660,21 +750,20 @@ export function attach( current.memoizedState != null && current.memoizedState.element != null; if (!wasMounted && isMounted) { // Mount a new root. - mountFiber(current); + mountFiber(current, null); } else if (wasMounted && isMounted) { // Update an existing root. - updateFiber(current, alternate); + updateFiber(current, alternate, null); } else if (wasMounted && !isMounted) { // Unmount an existing root. enqueueUnmount(current); } } else { // Mount a new root. - mountFiber(current); + mountFiber(current, null); } - markRootCommitted(current); // We're done here. - flushPendingEvents(); + flushPendingEvents(root); } // The naming is confusing. diff --git a/src/backend/types.js b/src/backend/types.js index 58cc727ffa..ab05c3c307 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -12,10 +12,9 @@ export type Fiber = Object; // TODO: If it's useful for the frontend to know which types of data an Element has // (e.g. props, state, context, hooks) then we could add a bitmask field for this // to keep the number of attributes small. -export type RendererData = {| +export type FiberData = {| key: React$Key | null, displayName: string | null, - children: Array, type: ElementType, |}; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000000..bcaa8224fc --- /dev/null +++ b/src/constants.js @@ -0,0 +1,5 @@ +// @flow + +export const TREE_OPERATION_ADD = 1; +export const TREE_OPERATION_REMOVE = 2; +export const TREE_OPERATION_RESET_CHILDREN = 3; diff --git a/src/devtools/store.js b/src/devtools/store.js index b168fbbf9c..7d0575147a 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -1,73 +1,83 @@ // @flow import EventEmitter from 'events'; +import { + TREE_OPERATION_ADD, + TREE_OPERATION_REMOVE, + TREE_OPERATION_RESET_CHILDREN, +} from '../constants'; +import { utfDecodeString } from '../utils'; -import type { Element, ElementTreeMetadata } from './types'; +import type { Element, ElementType } from './types'; import type { Bridge } from '../types'; const debug = (methodName, ...args) => { - console.log(`%cStore %c${methodName}`, 'color: red; font-weight: bold;', 'font-weight: bold;', ...args); + console.log( + `%cStore %c${methodName}`, + 'color: green; font-weight: bold;', + 'font-weight: bold;', + ...args + ); }; /** * The store is the single source of truth for updates from the backend. * 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(); +export default class Store extends EventEmitter { + // TODO Should items in this map be read-only for easier props comparison? + _idToElement: Map = new Map(); // Total number of visible elements (within all roots). // Used for windowing purposes. - numElements: number = 0; + _numElements: number = 0; // This Array must be treated as immutable! // Passive effects will check it for changes between render and mount. - roots: $ReadOnlyArray = []; + _roots: $ReadOnlyArray = []; constructor(bridge: Bridge) { super(); - bridge.on('root', this.onBridgeRoot); + bridge.on('operations', this.onBridgeOperations); bridge.on('rootCommitted', this.onBridgeRootCommitted); - - bridge.on('mount', this.onBridgeMount); - bridge.on('update', this.onBridgeUpdated); - bridge.on('unmount', this.onBridgeUnmounted); } - getElementAtIndex(index: number): Element | null { + get numElements(): number { + return this._numElements; + } + + get roots(): $ReadOnlyArray { + return this._roots; + } + + getElementAtIndex(index: number): Element { if (index < 0 || index >= this.numElements) { - return null; + throw Error(`Invalid index ${index} specified`); } + // Find wich root this element is in... 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) { + for (let i = 0; i < this._roots.length; i++) { + rootID = this._roots[i]; + root = ((this._idToElement.get(rootID): any): Element); + if (rootWeight + root.weight > index) { break; + } else { + rootWeight += root.weight; } } - let currentElement = root; + // Crawl the tree to find the correct root... + let currentElement = ((root: any): Element); 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); + const child = ((this._idToElement.get(childID): any): Element); + const { weight } = child; if (index <= currentWeight + weight) { currentWeight++; currentElement = child; @@ -78,207 +88,191 @@ class Store extends EventEmitter { } } - return currentElement; + return ((currentElement: any): Element); } - getElementByID(id: string) { - return this._idToElement.get(id); - } - - geParent(id: string) { - return this._idToParentID.get(id); - } - - getTreeMetadataForElement(element: Element) { - return this._elementToElementTreeMetadata.get(element); - } - - _crawlForTreeMetadata(id: string, depth: number = 0): number { - let weight = 1; - + getElementByID(id: number): Element { 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; + throw Error(`No element found with id "${id}`); } - element.children.forEach(childID => { - weight += this._crawlForTreeMetadata(childID, depth + 1); - }); - - this._elementToElementTreeMetadata.set(element, { - depth, - weight, - }); - - return weight; + return ((element: any): Element); } - _updateElementTreeMetadata(prevElement: Element, element: Element): void { - if (prevElement.children === element.children) { - return; - } + onBridgeOperations = (operations: Uint32Array) => { + debug('onBridgeOperations', operations); - // 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. + let haveRootsChanged = false; - const prevChildren = prevElement.children; - const prevNumChildren = prevChildren.length; + let i = 0; + while (i < operations.length) { + let id: number = ((null: any): number); + let element: Element = ((null: any): Element); + let parentID: number = ((null: any): number); + let parentElement: Element = ((null: any): Element); + let type: ElementType = ((null: any): ElementType); + let weightDelta: number = 0; - const children = element.children; - const numChildren = children.length; + const operation = operations[i]; - // TODO: The below diffing could be optimized more. + switch (operation) { + case TREE_OPERATION_ADD: + id = ((operations[i + 1]: any): number); + type = ((operations[i + 2]: any): ElementType); + parentID = ((operations[i + 3]: any): number); - // 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); + i = i + 4; - this.numElements -= weight; + if (parentID === 0) { + debug('Add', `new root fiber ${id}`); - let current = element; - while (current !== null) { - const datum = this._elementToElementTreeMetadata.get(current); - datum.weight -= weight; + this._roots = this._roots.concat(id); - const parent = this._idToElement.get(datum.parentID); - current = - parent != null - ? this._elementToElementTreeMetadata.get(parent) - : null; - } + this._idToElement.set(id, { + children: [], + depth: 0, + displayName: null, + id, + key: null, + parentID: 0, + type, + weight: 1, + }); + + haveRootsChanged = true; + } else { + const displayNameLength = operations[i]; + i++; + const displayName = + displayNameLength === 0 + ? null + : utfDecodeString( + (operations.slice(i, i + displayNameLength): any) + ); + i += displayNameLength; + + const keyLength = operations[i]; + i++; + const key = + keyLength === 0 + ? null + : utfDecodeString((operations.slice(i, i + keyLength): any)); + i += +keyLength; + + debug('Add', `fiber ${id}, type ${type}, as child of ${parentID}`); + + // TODO Fix this; there should not be duplicate "ADD" operations for a given element. + if (this._idToElement.has(id)) { + console.warn( + `fiber ${id}, type ${type}, already added as child of ${parentID}` + ); + } else { + parentElement = ((this._idToElement.get(parentID): any): Element); + parentElement.children = parentElement.children.concat(id); + + this._idToElement.set(id, { + children: [], + depth: parentElement.depth + 1, + displayName, + id, + key, + parentID: parentElement.id, + type, + weight: 1, + }); + + weightDelta = 1; + } + } + break; + case TREE_OPERATION_REMOVE: + id = ((operations[i + 1]: any): number); + + i = i + 2; + + debug('Remove', `fiber ${id} from tree`); + + element = ((this._idToElement.get(id): any): Element); + parentID = element.parentID; + + weightDelta = -element.weight; + + this._idToElement.delete(id); + + parentElement = ((this._idToElement.get(parentID): any): Element); + if (parentElement != null) { + parentElement.children = parentElement.children.filter( + childID => childID !== id + ); + } + break; + case TREE_OPERATION_RESET_CHILDREN: + id = ((operations[i + 1]: any): number); + const numChildren = ((operations[i + 2]: any): number); + const children = ((operations.slice( + i + 3, + i + 3 + numChildren + ): any): Array); + + i = i + 3 + numChildren; + + debug('Re-order', `fiber ${id} children ${children.join(',')}`); + + element = ((this._idToElement.get(id): any): Element); + element.children = children; + + const prevWeight = element.weight; + let childWeight = 0; + + children.forEach(childID => { + const child = ((this._idToElement.get(childID): any): Element); + childWeight += child.weight; + }); + + element.weight = childWeight + 1; + weightDelta = childWeight + 1 - prevWeight; + break; + default: + throw Error(`Unsupported Bridge operation ${operation}`); + } + + this._numElements += weightDelta; + + while (parentElement != null) { + parentElement.weight += weightDelta; + parentElement = ((this._idToElement.get( + parentElement.parentID + ): any): Element); } } - // 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); - this._idToElement.set(id, element); - - element.children.forEach(childID => { - this._idToParentID.set(childID, id); - }); - - this.emit(id); - }; - - onBridgeRoot = (id: string) => { - 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); - + if (haveRootsChanged) { this.emit('roots'); } }; - onBridgeRootCommitted = (rootID: string) => { - this._pendingDeletions.forEach(id => { - this._idToElement.delete(id); + onBridgeRootCommitted = (rootID: number) => { + debug('onBridgeRootCommitted', rootID); - if (this._idToParentID.has(id)) { - this._idToParentID.delete(id); - } - }); - this._pendingDeletions.clear(); + this.emit('rootCommitted'); - debug('onBridgeRootCommitted()', rootID); - this.emit('rootCommitted', rootID); - -this.__printTree(); + // this.__printTree(rootID); }; - // 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._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'); - } - }; - - 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); + // DEBUG + __printTree = (rootID: number) => { + const printElement = (id: number) => { + const element = ((this._idToElement.get(id): any): Element); + console.log( + `${' '.repeat(element.depth)}${element.id}:${element.displayName || + ''}${element.key ? `key:"${element.key}"` : ''} (${element.weight})` + ); + element.children.forEach(printElement); + }; + const root = ((this._idToElement.get(rootID): any): Element); + console.log('printing root:', rootID); + root.children.forEach(printElement); }; } - -export default Store; diff --git a/src/devtools/types.js b/src/devtools/types.js index c8cc329b41..a077523611 100644 --- a/src/devtools/types.js +++ b/src/devtools/types.js @@ -1,6 +1,6 @@ // @flow -import typeof Store from './Store'; +import Store from './Store'; export const ElementTypeClassOrFunction = 1; export const ElementTypeContext = 2; @@ -8,28 +8,26 @@ export const ElementTypeForwardRef = 3; export const ElementTypeMemo = 4; export const ElementTypeOtherOrUnknown = 5; export const ElementTypeProfiler = 6; -export const ElementTypeSuspense = 7; +export const ElementTypeRoot = 7; +export const ElementTypeSuspense = 8; -export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7; +export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; // TODO: Add profiling node export type Element = {| - id: string, - type: ElementType, - key: React$Key | null, - displayName: string | null, - children: Array, -|}; - -export type ElementTreeMetadata = {| + children: Array, depth: number, - rootID: string, + displayName: string | null, + id: number, + key: number | string | null, + parentID: number, + type: ElementType, weight: number, |}; export type InspectedElement = {| - id: string, + id: number, context: Object | null, hooks: Object | null, props: Object | null, @@ -38,7 +36,7 @@ export type InspectedElement = {| source: Object, |}; -export type TreeContext = {| +export type TreeMetadataType = {| 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 09cc97c902..4eb88a2c47 100644 --- a/src/devtools/views/Element.js +++ b/src/devtools/views/Element.js @@ -5,13 +5,13 @@ import { TreeContext } from './contexts'; import styles from './Element.css'; -type Props = {| +type Props = { index: number, style: Object, -|}; +}; export default function Element({ index, style }: Props) { - const {store} = useContext(TreeContext); + const { store } = useContext(TreeContext); const element = store.getElementAtIndex(index); // DevTools are rendered in concurrent mode. @@ -22,10 +22,7 @@ export default function Element({ index, style }: Props) { return null; } - const elementTreeMetadata = store.getTreeMetadataForElement(element); - - const { children, displayName, key } = element; - const { depth } = elementTreeMetadata; + const { children, depth, displayName, key } = element; // TODO: Add state for toggling element open/close @@ -34,7 +31,7 @@ export default function Element({ index, style }: Props) { className={styles.Element} style={{ ...style, - paddingLeft: `${1 + depth}rem` + paddingLeft: `${1 + depth}rem`, }} > {children.length > 0 && } diff --git a/src/devtools/views/Elements.js b/src/devtools/views/Elements.js index 6f6619661d..60afcc048c 100644 --- a/src/devtools/views/Elements.js +++ b/src/devtools/views/Elements.js @@ -15,12 +15,12 @@ export type Props = {| |}; export default function Elements({ bridge, browserName, themeName }: Props) { - const store = useMemo(() => new Store(bridge), []); + const store = useMemo(() => new Store(bridge), []); - const [treeContext, setTreeContext] = useState(({ + const [treeContext, setTreeContext] = useState({ size: store.numElements, store, - })); + }); useLayoutEffect(() => { const handler = () => { diff --git a/src/devtools/views/contexts.js b/src/devtools/views/contexts.js index f7428d3fc6..5e2b1be93e 100644 --- a/src/devtools/views/contexts.js +++ b/src/devtools/views/contexts.js @@ -2,10 +2,12 @@ import { createContext } from 'react'; -import type { TreeMetadata } from '../types'; +import type { TreeMetadataType } 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 +export const TreeContext = createContext( + ((null: any): TreeMetadataType) +); diff --git a/src/devtools/views/hooks.js b/src/devtools/views/hooks.js index 9576528afa..4fb62d4d9e 100644 --- a/src/devtools/views/hooks.js +++ b/src/devtools/views/hooks.js @@ -2,14 +2,14 @@ import { useLayoutEffect, useState } from 'react'; -import type { Element, ElementTreeMetadata } from '../types'; +import type { Element } from '../types'; import Store from '../store'; // TODO useEffect has a bug where sometimes subscriptions don't get cleaned up correctly. // Potentially related to github.com/facebookincubator/redux-react-hook/issues/17 // As a temporary work around, switch back to layout effect. -export function useElement(store: Store, id: string): ?Element { +export function useElement(store: Store, id: number): ?Element { const [element, setElement] = useState(store.getElementByID(id)); useLayoutEffect(() => { @@ -17,7 +17,7 @@ export function useElement(store: Store, id: string): ?Element { setElement(((store.getElementByID(id): any): Element)); // Listen for changes to the element. - store.addListener(id, handler); + store.addListener((id: any), handler); // Check for changes that may have happened between render and mount. const newElement = store.getElementByID(id); @@ -26,14 +26,14 @@ export function useElement(store: Store, id: string): ?Element { } // Remove event listener on unmount. - return () => store.removeListener(id, handler); + return () => store.removeListener((id: any), handler); }, [store, id]); return element; } -export function useRoots(store: Store): $ReadOnlyArray { - const [roots, setRoots] = useState<$ReadOnlyArray>(store.roots); +export function useRoots(store: Store): $ReadOnlyArray { + const [roots, setRoots] = useState<$ReadOnlyArray>(store.roots); useLayoutEffect(() => { const handler = () => setRoots(store.roots); diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000000..9f7e116527 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,19 @@ +// @flow + +export function utfDecodeString(array: Uint8Array): string { + let string = ''; + const { length } = array; + for (let i = 0; i < length; i++) { + string += String.fromCharCode(array[i]); + } + return string; +} + +export function utfEncodeString(string: string): Uint8Array { + const array = new Uint8Array(string.length); + const { length } = string; + for (let i = 0; i < length; i++) { + array[i] = string.charCodeAt(i); + } + return array; +} diff --git a/yarn.lock b/yarn.lock index 188fbf67a9..7a18f52559 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8422,10 +8422,10 @@ react-virtualized-auto-sizer@^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.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== +react-window@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.5.1.tgz#9d68624d5ba58ddf331321fb74b0a75184523d16" + integrity sha512-D855yW104ek8+gSLW8G6Dt69WnhSlGXR4Ld+IoZW4aQg6a1pt7cZLb+fcOugVDVgl4RfrLSfg9aLBnvS7zsOaw== dependencies: "@babel/runtime" "^7.0.0" memoize-one "^3.1.1"