Replaced node objects with typed array of tree operations; windowing works in small test harness

This commit is contained in:
Brian Vaughn
2019-01-28 15:50:48 -08:00
parent c9a5f8cc29
commit 3f93b029a4
14 changed files with 545 additions and 585 deletions
+1 -1
View File
@@ -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",
+22 -163
View File
@@ -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<Fiber, string> = new WeakMap();
_idToElement: Map<string, Element> = new Map();
_idToFiber: Map<string, Fiber> = new Map();
_idToRendererData: Map<string, RendererData> = new Map();
_idToRendererID: Map<string, RendererID> = new Map();
_bridge: Bridge = ((null: any): Bridge);
_rendererInterfaces: { [key: RendererID]: RendererInterface } = {};
_roots: Set<RendererID> = 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<number, string>) => {
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<number, string>) => {
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;
}
+1 -3
View File
@@ -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
];
+263 -174
View File
@@ -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<Fiber> = 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<Fiber, number> = 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<number> = [];
// 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<number>) {
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.
+1 -2
View File
@@ -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<Fiber>,
type: ElementType,
|};
+5
View File
@@ -0,0 +1,5 @@
// @flow
export const TREE_OPERATION_ADD = 1;
export const TREE_OPERATION_REMOVE = 2;
export const TREE_OPERATION_RESET_CHILDREN = 3;
+198 -204
View File
@@ -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<string, Element> = new Map();
_idToParentID: Map<string, string> = new Map();
_pendingDeletions: Set<string> = new Set();
export default class Store extends EventEmitter {
// TODO Should items in this map be read-only for easier props comparison?
_idToElement: Map<number, Element> = 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<string> = [];
_roots: $ReadOnlyArray<number> = [];
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<number> {
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<number>);
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;
+13 -15
View File
@@ -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<string>,
|};
export type ElementTreeMetadata = {|
children: Array<number>,
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,
|};
|};
+5 -8
View File
@@ -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 && <span className={styles.ArrowOpen} />}
+3 -3
View File
@@ -15,12 +15,12 @@ export type Props = {|
|};
export default function Elements({ bridge, browserName, themeName }: Props) {
const store = useMemo(() => new Store(bridge), []);
const store = useMemo<Store>(() => new Store(bridge), []);
const [treeContext, setTreeContext] = useState(({
const [treeContext, setTreeContext] = useState({
size: store.numElements,
store,
}));
});
useLayoutEffect(() => {
const handler = () => {
+4 -2
View File
@@ -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<Array<string>>([]);
export const StoreContext = createContext<Store>(((null: any): Store));
export const TreeContext = createContext<TreeMetadata>(((null: any): TreeMetadata));
export const TreeContext = createContext<TreeMetadataType>(
((null: any): TreeMetadataType)
);
+6 -6
View File
@@ -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<?Element>(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<string> {
const [roots, setRoots] = useState<$ReadOnlyArray<string>>(store.roots);
export function useRoots(store: Store): $ReadOnlyArray<number> {
const [roots, setRoots] = useState<$ReadOnlyArray<number>>(store.roots);
useLayoutEffect(() => {
const handler = () => setRoots(store.roots);
+19
View File
@@ -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;
}
+4 -4
View File
@@ -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"