Files
react/src/devtools/store.js

632 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @flow
import EventEmitter from 'events';
import {
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_RESET_CHILDREN,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import { ElementTypeRoot } from './types';
import { utfDecodeString } from '../utils';
import { __DEBUG__ } from '../constants';
import ProfilingCache from './ProfilingCache';
import type { ElementType } from './types';
import type { Element } from './views/Elements/types';
import type { Bridge } from '../types';
const debug = (methodName, ...args) => {
if (__DEBUG__) {
console.log(
`%cStore %c${methodName}`,
'color: green; font-weight: bold;',
'font-weight: bold;',
...args
);
}
};
type Config = {|
supportsReloadAndProfile?: boolean,
supportsProfiling?: boolean,
|};
type ProfilingSnapshotNode = {|
id: number,
children: Array<number>,
displayName: string | null,
key: number | string | null,
|};
export type Capabilities = {|
supportsProfiling: boolean,
|};
/**
* 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.
*/
export default class Store extends EventEmitter {
_bridge: Bridge;
// Map of ID to Element.
// Elements are mutable (for now) to avoid excessive cloning during tree updates.
_idToElement: Map<number, Element> = new Map();
// The backend is currently profiling.
// When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
_isProfiling: boolean = false;
// Total number of visible elements (within all roots).
// Used for windowing purposes.
_numElements: number = 0;
// Suspense cache for reading profilign data.
_profilingCache: ProfilingCache;
// Map of root (id) to a list of tree mutation that occur during profiling.
// Once profiling is finished, these mutations can be used, along with the initial tree snapshots,
// to reconstruct the state of each root for each commit.
_profilingOperations: Map<number, Array<Uint32Array>> = new Map();
// Snapshot of the state of the main Store (including all roots) when profiling started.
// Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling,
// to reconstruct the state of each root for each commit.
// It's okay to use a single root to store this information because node IDs are unique across all roots.
_profilingSnapshot: Map<number, ProfilingSnapshotNode> = new Map();
// Incremented each time the store is mutated.
// This enables a passive effect to detect a mutation between render and commit phase.
_revision: number = 0;
// This Array must be treated as immutable!
// Passive effects will check it for changes between render and mount.
_roots: $ReadOnlyArray<number> = [];
_rootIDToCapabilities: Map<number, Capabilities> = new Map();
// Renderer ID is needed to support inspection fiber props, state, and hooks.
_rootIDToRendererID: Map<number, number> = new Map();
_supportsProfiling: boolean = false;
_supportsReloadAndProfile: boolean = false;
constructor(bridge: Bridge, config?: Config) {
super();
debug('constructor', 'subscribing to Bridge');
if (config != null) {
if (config.supportsProfiling) {
this._supportsProfiling = true;
}
if (config.supportsReloadAndProfile) {
this._supportsReloadAndProfile = true;
}
}
this._bridge = bridge;
bridge.addListener('operations', this.onBridgeOperations);
bridge.addListener('profilingStatus', this.onProfilingStatus);
bridge.addListener('shutdown', this.onBridgeShutdown);
// It's possible that profiling has already started (e.g. "reload and start profiling")
// so the frontend needs to ask the backend for its status after mounting.
bridge.send('getProfilingStatus');
this._profilingCache = new ProfilingCache(bridge, this);
}
// Profiling data has been recorded for at least one root.
get hasProfilingData(): boolean {
return this._profilingOperations.size > 0;
}
get isProfiling(): boolean {
return this._isProfiling;
}
get numElements(): number {
return this._numElements;
}
get profilingCache(): ProfilingCache {
return this._profilingCache;
}
get profilingOperations(): Map<number, Array<Uint32Array>> {
return this._profilingOperations;
}
get profilingSnapshot(): Map<number, ProfilingSnapshotNode> {
return this._profilingSnapshot;
}
get revision(): number {
return this._revision;
}
get roots(): $ReadOnlyArray<number> {
return this._roots;
}
get supportsProfiling(): boolean {
return this._supportsProfiling;
}
get supportsReloadAndProfile(): boolean {
return this._supportsReloadAndProfile;
}
getElementAtIndex(index: number): Element | null {
if (index < 0 || index >= this.numElements) {
console.warn(
`Invalid index ${index} specified; store contains ${
this.numElements
} items.`
);
return null;
}
// 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): any): Element);
if (root.children.length === 0) {
continue;
} else if (rootWeight + root.weight > index) {
break;
} else {
rootWeight += root.weight;
}
}
// Find the element in the tree using the weight of each node...
// Skip over the root itself, because roots aren't visible in the Elements tree.
const firstChildID = ((root: any): Element).children[0];
let currentElement = ((this._idToElement.get(firstChildID): any): Element);
let currentWeight = rootWeight;
while (index !== currentWeight) {
for (let i = 0; i < currentElement.children.length; i++) {
const childID = currentElement.children[i];
const child = ((this._idToElement.get(childID): any): Element);
const { weight } = child;
if (index <= currentWeight + weight) {
currentWeight++;
currentElement = child;
break;
} else {
currentWeight += weight;
}
}
}
return ((currentElement: any): Element) || null;
}
getElementIDAtIndex(index: number): number | null {
const element: Element | null = this.getElementAtIndex(index);
return element === null ? null : element.id;
}
getElementByID(id: number): Element | null {
const element = this._idToElement.get(id);
if (element == null) {
console.warn(`No element found with id "${id}"`);
return null;
}
return element;
}
getIndexOfElementID(id: number): number | null {
const element = this.getElementByID(id);
if (element === null || element.parentID === 0) {
return null;
}
// Walk up the tree to the root.
// Increment the index by one for each node we encounter,
// and by the weight of all nodes to the left of the current one.
// This should be a relatively fast way of determining the index of a node within the tree.
let previousID = id;
let currentID = element.parentID;
let index = 0;
while (true) {
const current = ((this._idToElement.get(currentID): any): Element);
if (current.parentID === 0) {
// We found the root; stop crawling.
break;
}
index++;
const { children } = current;
for (let i = 0; i < children.length; i++) {
const childID = children[i];
if (childID === previousID) {
break;
}
const child = ((this._idToElement.get(childID): any): Element);
index += child.weight;
}
previousID = current.id;
currentID = current.parentID;
}
// At this point, the current ID is a root (from the previous loop).
// We also need to offset the index by previous root weights.
for (let i = 0; i < this._roots.length; i++) {
const rootID = this._roots[i];
if (rootID === currentID) {
break;
}
const root = ((this._idToElement.get(rootID): any): Element);
index += root.weight;
}
return index;
}
getRendererIDForElement(id: number): number | null {
let current = this._idToElement.get(id);
while (current != null) {
if (current.parentID === 0) {
const rendererID = this._rootIDToRendererID.get(current.id);
return rendererID == null ? null : ((rendererID: any): number);
} else {
current = this._idToElement.get(current.parentID);
}
}
return null;
}
getRootIDForElement(id: number): number | null {
let current = this._idToElement.get(id);
while (current != null) {
if (current.parentID === 0) {
return current.id;
} else {
current = this._idToElement.get(current.parentID);
}
}
return null;
}
startProfiling(): void {
this._bridge.send('startProfiling');
// Invalidate suspense cache if profiling data is being (re-)recorded.
// Note that we clear now because any existing data is "stale".
this._profilingCache.invalidate();
this._isProfiling = false;
this.emit('isProfiling');
}
stopProfiling(): void {
this._bridge.send('stopProfiling');
// Invalidate suspense cache if profiling data is being (re-)recorded.
// Note that we clear again, in case any views read from the cache while profiling.
// (That would have resolved a now-stale value without any profiling data.)
this._profilingCache.invalidate();
this._isProfiling = false;
this.emit('isProfiling');
}
_takeProfilingSnapshotRecursive = (id: number) => {
const element = this.getElementByID(id);
if (element !== null) {
this._profilingSnapshot.set(id, {
id,
children: element.children.slice(0),
displayName: element.displayName,
key: element.key,
});
element.children.forEach(this._takeProfilingSnapshotRecursive);
}
};
onBridgeOperations = (operations: Uint32Array) => {
if (!(operations instanceof Uint32Array)) {
// $FlowFixMe TODO HACK Temporary workaround for the fact that Chrome is not transferring the typed array.
operations = Uint32Array.from(Object.values(operations));
}
debug('onBridgeOperations', operations);
let haveRootsChanged = false;
const rendererID = operations[0];
const rootID = operations[1];
if (this._isProfiling) {
const profilingOperations = this._profilingOperations.get(rootID);
if (profilingOperations == null) {
this._profilingOperations.set(rootID, [operations]);
} else {
profilingOperations.push(operations);
}
}
let addedElementIDs: Uint32Array = new Uint32Array(0);
let removedElementIDs: Uint32Array = new Uint32Array(0);
let i = 2;
while (i < operations.length) {
let id: number = ((null: any): number);
let element: Element = ((null: any): Element);
let ownerID: number = 0;
let parentID: number = ((null: any): number);
let parentElement: Element = ((null: any): Element);
let type: ElementType = ((null: any): ElementType);
let weightDelta: number = 0;
const operation = operations[i];
switch (operation) {
case TREE_OPERATION_ADD:
id = ((operations[i + 1]: any): number);
type = ((operations[i + 2]: any): ElementType);
i = i + 3;
if (type === ElementTypeRoot) {
debug('Add', `new root fiber ${id}`);
if (this._idToElement.has(id)) {
// The renderer's tree walking approach sometimes mounts the same Fiber twice with Suspense and Lazy.
// For now, we avoid adding it to the tree twice by checking if it's already been mounted.
// Maybe in the future we'll revisit this.
} else {
const supportsProfiling = operations[i] > 0;
i++;
this._roots = this._roots.concat(id);
this._rootIDToRendererID.set(id, rendererID);
this._rootIDToCapabilities.set(id, { supportsProfiling });
this._idToElement.set(id, {
children: [],
depth: -1,
displayName: null,
id,
key: null,
ownerID: 0,
parentID: 0,
type,
weight: 0,
});
haveRootsChanged = true;
}
} else {
parentID = ((operations[i]: any): number);
i++;
ownerID = ((operations[i]: any): number);
i++;
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} (${displayName || 'null'}) as child of ${parentID}`
);
if (this._idToElement.has(id)) {
// The renderer's tree walking approach sometimes mounts the same Fiber twice with Suspense and Lazy.
// For now, we avoid adding it to the tree twice by checking if it's already been mounted.
// Maybe in the future we'll revisit this.
} else {
parentElement = ((this._idToElement.get(parentID): any): Element);
parentElement.children = parentElement.children.concat(id);
const element: Element = {
children: [],
depth: parentElement.depth + 1,
displayName,
id,
key,
ownerID,
parentID: parentElement.id,
type,
weight: 1,
};
this._idToElement.set(id, element);
const oldAddedElementIDs = addedElementIDs;
addedElementIDs = new Uint32Array(addedElementIDs.length + 1);
addedElementIDs.set(oldAddedElementIDs);
addedElementIDs[oldAddedElementIDs.length] = id;
weightDelta = 1;
}
}
break;
case TREE_OPERATION_REMOVE:
id = ((operations[i + 1]: any): number);
i = i + 2;
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) {
debug('Remove', `fiber ${id} root`);
this._roots = this._roots.filter(rootID => rootID !== id);
this._rootIDToRendererID.delete(id);
this._rootIDToCapabilities.delete(id);
haveRootsChanged = true;
} else {
debug('Remove', `fiber ${id} from parent ${parentID}`);
parentElement.children = parentElement.children.filter(
childID => childID !== id
);
}
// Track removed items so search results can be updated
const oldRemovededElementIDs = removedElementIDs;
removedElementIDs = new Uint32Array(removedElementIDs.length + 1);
removedElementIDs.set(oldRemovededElementIDs);
removedElementIDs[oldRemovededElementIDs.length] = 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 = Array.from(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;
case TREE_OPERATION_UPDATE_TREE_BASE_DURATION:
// Base duration updates are only sent while profiling is in progress.
// We can ignore them at this point.
// The profiler UI uses them lazily in order to generate the tree.
i = i + 3;
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);
}
}
this._revision++;
if (haveRootsChanged) {
this._supportsProfiling = false;
this._rootIDToCapabilities.forEach(({ supportsProfiling }) => {
if (supportsProfiling) {
this._supportsProfiling = true;
}
});
this.emit('roots');
}
this.emit('mutated', [addedElementIDs, removedElementIDs]);
};
onProfilingStatus = (isProfiling: boolean) => {
if (isProfiling) {
this._profilingOperations = new Map();
this._profilingSnapshot = new Map();
this.roots.forEach(this._takeProfilingSnapshotRecursive);
}
if (this._isProfiling !== isProfiling) {
this._isProfiling = isProfiling;
this.emit('isProfiling');
}
};
onBridgeShutdown = () => {
debug('onBridgeShutdown', 'unsubscribing from Bridge');
this._bridge.removeListener('operations', this.onBridgeOperations);
this._bridge.removeListener('profilingStatus', this.onProfilingStatus);
this._bridge.removeListener('shutdown', this.onBridgeShutdown);
};
// DEBUG
__printTree = () => {
if (__DEBUG__) {
console.group('__printTree()');
this._roots.forEach((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.group(`${rootID}:root (${root.weight})`);
root.children.forEach(printElement);
console.groupEnd();
});
console.group(`List of ${this.numElements} elements`);
for (let i = 0; i < this.numElements; i++) {
//if (i === 4) { debugger }
const element = this.getElementAtIndex(i);
if (element != null) {
console.log(
`${'•'.repeat(element.depth)}${i}: ${element.displayName ||
'Unknown'}`
);
}
}
console.groupEnd();
console.groupEnd();
}
};
}