mirror of
https://github.com/facebook/react.git
synced 2026-02-23 20:23:02 +00:00
1102 lines
35 KiB
JavaScript
1102 lines
35 KiB
JavaScript
// @flow
|
||
|
||
import EventEmitter from 'events';
|
||
import memoize from 'memoize-one';
|
||
import throttle from 'lodash.throttle';
|
||
import {
|
||
TREE_OPERATION_ADD,
|
||
TREE_OPERATION_REMOVE,
|
||
TREE_OPERATION_REORDER_CHILDREN,
|
||
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
|
||
} from '../constants';
|
||
import { ElementTypeRoot } from '../types';
|
||
import {
|
||
getSavedComponentFilters,
|
||
saveComponentFilters,
|
||
utfDecodeString,
|
||
} from '../utils';
|
||
import { __DEBUG__ } from '../constants';
|
||
import ProfilingCache from './ProfilingCache';
|
||
import { printStore } from 'src/__tests__/storeSerializer';
|
||
|
||
import type { Element } from './views/Components/types';
|
||
import type {
|
||
ImportedProfilingData,
|
||
ProfilingSnapshotNode,
|
||
} from './views/Profiler/types';
|
||
import type { Bridge, ComponentFilter, ElementType } from '../types';
|
||
|
||
const debug = (methodName, ...args) => {
|
||
if (__DEBUG__) {
|
||
console.log(
|
||
`%cStore %c${methodName}`,
|
||
'color: green; font-weight: bold;',
|
||
'font-weight: bold;',
|
||
...args
|
||
);
|
||
}
|
||
};
|
||
|
||
const LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY =
|
||
'React::DevTools::captureScreenshots';
|
||
const LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY =
|
||
'React::DevTools::collapseNodesByDefault';
|
||
|
||
const THROTTLE_CAPTURE_SCREENSHOT_DURATION = 500;
|
||
|
||
type Config = {|
|
||
isProfiling?: boolean,
|
||
supportsCaptureScreenshots?: boolean,
|
||
supportsFileDownloads?: boolean,
|
||
supportsReloadAndProfile?: boolean,
|
||
supportsProfiling?: boolean,
|
||
|};
|
||
|
||
export type Capabilities = {|
|
||
hasOwnerMetadata: boolean,
|
||
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;
|
||
|
||
_captureScreenshots: boolean = false;
|
||
|
||
// Should new nodes be collapsed by default when added to the tree?
|
||
_collapseNodesByDefault: boolean = true;
|
||
|
||
_componentFilters: Array<ComponentFilter>;
|
||
|
||
// At least one of the injected renderers contains (DEV only) owner metadata.
|
||
_hasOwnerMetadata: boolean = false;
|
||
|
||
// Map of ID to (mutable) Element.
|
||
// Elements are mutated to avoid excessive cloning during tree updates.
|
||
// The InspectedElementContext also relies on this mutability for its WeakMap usage.
|
||
_idToElement: Map<number, Element> = new Map();
|
||
|
||
// The user has imported a previously exported profiling session.
|
||
_importedProfilingData: ImportedProfilingData | null = null;
|
||
|
||
// 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;
|
||
|
||
// Map of element (id) to the set of elements (ids) it owns.
|
||
// This map enables getOwnersListForElement() to avoid traversing the entire tree.
|
||
_ownersMap: Map<number, Set<number>> = new Map();
|
||
|
||
// Suspense cache for reading profiling 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.
|
||
_profilingOperationsByRootID: Map<number, Array<Uint32Array>> = new Map();
|
||
|
||
// Map of root (id) to a Map of screenshots by commit ID.
|
||
// Stores screenshots for each commit (when profiling).
|
||
_profilingScreenshotsByRootID: Map<number, Map<number, string>> = 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.
|
||
_profilingSnapshotsByElementID: 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();
|
||
|
||
// These options may be initially set by a confiugraiton option when constructing the Store.
|
||
// In the case of "supportsProfiling", the option may be updated based on the injected renderers.
|
||
_supportsCaptureScreenshots: boolean = false;
|
||
_supportsFileDownloads: boolean = false;
|
||
_supportsProfiling: boolean = false;
|
||
_supportsReloadAndProfile: boolean = false;
|
||
|
||
// Total number of visible elements (within all roots).
|
||
// Used for windowing purposes.
|
||
_weightAcrossRoots: number = 0;
|
||
|
||
constructor(bridge: Bridge, config?: Config) {
|
||
super();
|
||
|
||
if (__DEBUG__) {
|
||
debug('constructor', 'subscribing to Bridge');
|
||
}
|
||
|
||
// Default this setting to true unless otherwise specified.
|
||
this._collapseNodesByDefault =
|
||
localStorage.getItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) !==
|
||
'false';
|
||
|
||
this._componentFilters = getSavedComponentFilters();
|
||
|
||
if (config != null) {
|
||
const {
|
||
isProfiling,
|
||
supportsCaptureScreenshots,
|
||
supportsFileDownloads,
|
||
supportsProfiling,
|
||
supportsReloadAndProfile,
|
||
} = config;
|
||
if (isProfiling) {
|
||
this._isProfiling = true;
|
||
}
|
||
if (supportsCaptureScreenshots) {
|
||
this._supportsCaptureScreenshots = true;
|
||
this._captureScreenshots =
|
||
localStorage.getItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) ===
|
||
'true';
|
||
}
|
||
if (supportsFileDownloads) {
|
||
this._supportsFileDownloads = true;
|
||
}
|
||
if (supportsProfiling) {
|
||
this._supportsProfiling = true;
|
||
}
|
||
if (supportsReloadAndProfile) {
|
||
this._supportsReloadAndProfile = true;
|
||
}
|
||
}
|
||
|
||
this._bridge = bridge;
|
||
bridge.addListener('operations', this.onBridgeOperations);
|
||
bridge.addListener('profilingStatus', this.onProfilingStatus);
|
||
bridge.addListener('screenshotCaptured', this.onScreenshotCaptured);
|
||
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);
|
||
}
|
||
|
||
// This is only used in tests to avoid memory leaks.
|
||
assertEmptyMaps() {
|
||
this.assertEmptyMap(this._idToElement, '_idToElement');
|
||
this.assertEmptyMap(this._ownersMap, '_ownersMap');
|
||
this.assertEmptyMap(
|
||
this._profilingOperationsByRootID,
|
||
'_profilingOperationsByRootID'
|
||
);
|
||
this.assertEmptyMap(
|
||
this._profilingScreenshotsByRootID,
|
||
'_profilingScreenshotsByRootID'
|
||
);
|
||
this.assertEmptyMap(
|
||
this._profilingSnapshotsByElementID,
|
||
'_profilingSnapshotsByElementID'
|
||
);
|
||
this.assertEmptyMap(this._rootIDToCapabilities, '_rootIDToCapabilities');
|
||
this.assertEmptyMap(this._rootIDToRendererID, '_rootIDToRendererID');
|
||
}
|
||
|
||
// This is only used in tests to avoid memory leaks.
|
||
assertEmptyMap(map: Map<any, any>, mapName: string) {
|
||
if (map.size !== 0) {
|
||
throw new Error(
|
||
`Expected ${mapName} to be empty, got ${
|
||
map.size
|
||
}: ${require('util').inspect(this, { depth: 20 })}`
|
||
);
|
||
}
|
||
}
|
||
|
||
get captureScreenshots(): boolean {
|
||
return this._captureScreenshots;
|
||
}
|
||
set captureScreenshots(value: boolean): void {
|
||
this._captureScreenshots = value;
|
||
|
||
localStorage.setItem(
|
||
LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY,
|
||
value ? 'true' : 'false'
|
||
);
|
||
|
||
this.emit('captureScreenshots');
|
||
}
|
||
|
||
get collapseNodesByDefault(): boolean {
|
||
return this._collapseNodesByDefault;
|
||
}
|
||
set collapseNodesByDefault(value: boolean): void {
|
||
this._collapseNodesByDefault = value;
|
||
|
||
localStorage.setItem(
|
||
LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY,
|
||
value ? 'true' : 'false'
|
||
);
|
||
|
||
this.emit('collapseNodesByDefault');
|
||
}
|
||
|
||
get componentFilters(): Array<ComponentFilter> {
|
||
return this._componentFilters;
|
||
}
|
||
set componentFilters(value: Array<ComponentFilter>): void {
|
||
if (this._isProfiling) {
|
||
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
|
||
// If necessary, we could support this- but it doesn't seem like a necessary use case.
|
||
throw Error('Cannot modify filter preferences while profiling');
|
||
}
|
||
|
||
this._componentFilters = value;
|
||
|
||
// Update persisted filter preferences stored in localStorage.
|
||
saveComponentFilters(value);
|
||
|
||
// Notify the renderer that filter prefernces have changed.
|
||
// This is an expensive opreation; it unmounts and remounts the entire tree.
|
||
this._bridge.send('updateComponentFilters', value);
|
||
|
||
this.emit('componentFilters');
|
||
}
|
||
|
||
get hasOwnerMetadata(): boolean {
|
||
return this._hasOwnerMetadata;
|
||
}
|
||
|
||
// Profiling data has been recorded for at least one root.
|
||
get hasProfilingData(): boolean {
|
||
return (
|
||
this._importedProfilingData !== null ||
|
||
this._profilingOperationsByRootID.size > 0
|
||
);
|
||
}
|
||
|
||
get importedProfilingData(): ImportedProfilingData | null {
|
||
return this._importedProfilingData;
|
||
}
|
||
set importedProfilingData(value: ImportedProfilingData | null): void {
|
||
this._importedProfilingData = value;
|
||
this._profilingOperationsByRootID = new Map();
|
||
this._profilingScreenshotsByRootID = new Map();
|
||
this._profilingSnapshotsByElementID = new Map();
|
||
this._profilingCache.invalidate();
|
||
|
||
this.emit('importedProfilingData');
|
||
}
|
||
|
||
get isProfiling(): boolean {
|
||
return this._isProfiling;
|
||
}
|
||
|
||
get numElements(): number {
|
||
return this._weightAcrossRoots;
|
||
}
|
||
|
||
get profilingCache(): ProfilingCache {
|
||
return this._profilingCache;
|
||
}
|
||
|
||
get profilingOperations(): Map<number, Array<Uint32Array>> {
|
||
return this._profilingOperationsByRootID;
|
||
}
|
||
|
||
get profilingScreenshots(): Map<number, Map<number, string>> {
|
||
return this._profilingScreenshotsByRootID;
|
||
}
|
||
|
||
get profilingSnapshot(): Map<number, ProfilingSnapshotNode> {
|
||
return this._profilingSnapshotsByElementID;
|
||
}
|
||
|
||
get revision(): number {
|
||
return this._revision;
|
||
}
|
||
|
||
get roots(): $ReadOnlyArray<number> {
|
||
return this._roots;
|
||
}
|
||
|
||
get supportsCaptureScreenshots(): boolean {
|
||
return this._supportsCaptureScreenshots;
|
||
}
|
||
|
||
get supportsFileDownloads(): boolean {
|
||
return this._supportsFileDownloads;
|
||
}
|
||
|
||
get supportsProfiling(): boolean {
|
||
return this._supportsProfiling;
|
||
}
|
||
|
||
get supportsReloadAndProfile(): boolean {
|
||
return this._supportsReloadAndProfile;
|
||
}
|
||
|
||
clearProfilingData(): void {
|
||
this._importedProfilingData = null;
|
||
this._profilingOperationsByRootID = new Map();
|
||
this._profilingScreenshotsByRootID = new Map();
|
||
this._profilingSnapshotsByElementID = new Map();
|
||
|
||
// 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.emit('isProfiling');
|
||
}
|
||
|
||
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.
|
||
let currentElement = ((root: any): Element);
|
||
let currentWeight = rootWeight - 1;
|
||
while (index !== currentWeight) {
|
||
const numChildren = currentElement.children.length;
|
||
for (let i = 0; i < numChildren; i++) {
|
||
const childID = currentElement.children[i];
|
||
const child = ((this._idToElement.get(childID): any): Element);
|
||
const childWeight = child.isCollapsed ? 1 : child.weight;
|
||
|
||
if (index <= currentWeight + childWeight) {
|
||
currentWeight++;
|
||
currentElement = child;
|
||
break;
|
||
} else {
|
||
currentWeight += childWeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
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.isCollapsed ? 1 : child.weight;
|
||
}
|
||
|
||
if (current.parentID === 0) {
|
||
// We found the root; stop crawling.
|
||
break;
|
||
}
|
||
|
||
index++;
|
||
|
||
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;
|
||
}
|
||
|
||
getOwnersListForElement(ownerID: number): Array<Element> {
|
||
const list = [];
|
||
let element = this._idToElement.get(ownerID);
|
||
if (element != null) {
|
||
list.push({
|
||
...element,
|
||
depth: 0,
|
||
});
|
||
|
||
const unsortedIDs = this._ownersMap.get(ownerID);
|
||
if (unsortedIDs !== undefined) {
|
||
const depthMap: Map<number, number> = new Map([[ownerID, 0]]);
|
||
|
||
// Items in a set are ordered based on insertion.
|
||
// This does not correlate with their order in the tree.
|
||
// So first we need to order them.
|
||
// I wish we could avoid this sorting operation; we could sort at insertion time,
|
||
// but then we'd have to pay sorting costs even if the owners list was never used.
|
||
// Seems better to defer the cost, since the set of ids is probably pretty small.
|
||
const sortedIDs = Array.from(unsortedIDs).sort(
|
||
(idA, idB) =>
|
||
((this.getIndexOfElementID(idA): any): number) -
|
||
((this.getIndexOfElementID(idB): any): number)
|
||
);
|
||
|
||
// Next we need to determine the appropriate depth for each element in the list.
|
||
// The depth in the list may not correspond to the depth in the tree,
|
||
// because the list has been filtered to remove intermediate components.
|
||
// Perhaps the easiest way to do this is to walk up the tree until we reach either:
|
||
// (1) another node that's already in the tree, or (2) the root (owner)
|
||
// at which point, our depth is just the depth of that node plus one.
|
||
sortedIDs.forEach(id => {
|
||
const element = this._idToElement.get(id);
|
||
if (element != null) {
|
||
let parentID = element.parentID;
|
||
|
||
let depth = 0;
|
||
while (parentID > 0) {
|
||
if (parentID === ownerID || unsortedIDs.has(parentID)) {
|
||
depth = depthMap.get(parentID) + 1;
|
||
depthMap.set(id, depth);
|
||
break;
|
||
}
|
||
const parent = this._idToElement.get(parentID);
|
||
if (parent == null) {
|
||
break;
|
||
}
|
||
parentID = parent.parentID;
|
||
}
|
||
|
||
if (depth === 0) {
|
||
throw Error('Invalid owners list');
|
||
}
|
||
|
||
list.push({ ...element, depth });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
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;
|
||
} 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;
|
||
}
|
||
|
||
isInsideCollapsedSubTree(id: number): boolean {
|
||
let current = this._idToElement.get(id);
|
||
while (current != null) {
|
||
if (current.parentID === 0) {
|
||
return false;
|
||
} else {
|
||
current = this._idToElement.get(current.parentID);
|
||
if (current != null && current.isCollapsed) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
startProfiling(): void {
|
||
this._bridge.send('startProfiling');
|
||
|
||
// Don't actually update the local profiling boolean yet!
|
||
// Wait for onProfilingStatus() to confirm the status has changed.
|
||
// This ensures the frontend and backend are in sync wrt which commits were profiled.
|
||
// We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors.
|
||
}
|
||
|
||
stopProfiling(): void {
|
||
this._bridge.send('stopProfiling');
|
||
|
||
// Don't actually update the local profiling boolean yet!
|
||
// Wait for onProfilingStatus() to confirm the status has changed.
|
||
// This ensures the frontend and backend are in sync wrt which commits were profiled.
|
||
// We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors.
|
||
}
|
||
|
||
// TODO Maybe split this into two methods: expand() and collapse()
|
||
toggleIsCollapsed(id: number, isCollapsed: boolean): void {
|
||
let didMutate = false;
|
||
|
||
const element = this.getElementByID(id);
|
||
if (element !== null) {
|
||
if (isCollapsed) {
|
||
if (element.type === ElementTypeRoot) {
|
||
throw Error('Root nodes cannot be collapsed');
|
||
}
|
||
|
||
if (!element.isCollapsed) {
|
||
didMutate = true;
|
||
element.isCollapsed = true;
|
||
|
||
const weightDelta = 1 - element.weight;
|
||
|
||
let parentElement = ((this._idToElement.get(
|
||
element.parentID
|
||
): any): Element);
|
||
while (parentElement != null) {
|
||
// We don't need to break on a collapsed parent in the same way as the expand case below.
|
||
// That's because collapsing a node doesn't "bubble" and affect its parents.
|
||
parentElement.weight += weightDelta;
|
||
parentElement = this._idToElement.get(parentElement.parentID);
|
||
}
|
||
}
|
||
} else {
|
||
let currentElement = element;
|
||
while (currentElement != null) {
|
||
const oldWeight = currentElement.isCollapsed
|
||
? 1
|
||
: currentElement.weight;
|
||
|
||
if (currentElement.isCollapsed) {
|
||
didMutate = true;
|
||
currentElement.isCollapsed = false;
|
||
|
||
const newWeight = currentElement.isCollapsed
|
||
? 1
|
||
: currentElement.weight;
|
||
const weightDelta = newWeight - oldWeight;
|
||
|
||
let parentElement = ((this._idToElement.get(
|
||
currentElement.parentID
|
||
): any): Element);
|
||
while (parentElement != null) {
|
||
parentElement.weight += weightDelta;
|
||
if (parentElement.isCollapsed) {
|
||
// It's important to break on a collapsed parent when expanding nodes.
|
||
// That's because expanding a node "bubbles" up and expands all parents as well.
|
||
// Breaking in this case prevents us from over-incrementing the expanded weights.
|
||
break;
|
||
}
|
||
parentElement = this._idToElement.get(parentElement.parentID);
|
||
}
|
||
}
|
||
|
||
currentElement =
|
||
currentElement.parentID !== 0
|
||
? this.getElementByID(currentElement.parentID)
|
||
: null;
|
||
}
|
||
}
|
||
|
||
// Only re-calculate weights and emit an "update" event if the store was mutated.
|
||
if (didMutate) {
|
||
let weightAcrossRoots = 0;
|
||
this._roots.forEach(rootID => {
|
||
const { weight } = ((this.getElementByID(rootID): any): Element);
|
||
weightAcrossRoots += weight;
|
||
});
|
||
this._weightAcrossRoots = weightAcrossRoots;
|
||
|
||
// The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed.
|
||
// In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden).
|
||
// Updating the selected search index later may require auto-expanding a collapsed subtree though.
|
||
this.emit('mutated', [[], new Map()]);
|
||
}
|
||
}
|
||
}
|
||
|
||
_captureScreenshot = throttle(
|
||
memoize((rootID: number, commitIndex: number) => {
|
||
this._bridge.send('captureScreenshot', { commitIndex, rootID });
|
||
}),
|
||
THROTTLE_CAPTURE_SCREENSHOT_DURATION
|
||
);
|
||
|
||
_takeProfilingSnapshotRecursive = (elementID: number) => {
|
||
const element = this.getElementByID(elementID);
|
||
if (element !== null) {
|
||
this._profilingSnapshotsByElementID.set(elementID, {
|
||
id: elementID,
|
||
children: element.children.slice(0),
|
||
displayName: element.displayName,
|
||
key: element.key,
|
||
});
|
||
|
||
element.children.forEach(this._takeProfilingSnapshotRecursive);
|
||
}
|
||
};
|
||
|
||
_clearProfilingSnapshotRecursive = (elementID: number) => {
|
||
const element = this.getElementByID(elementID);
|
||
if (element !== null) {
|
||
this._profilingSnapshotsByElementID.delete(elementID);
|
||
|
||
element.children.forEach(this._clearProfilingSnapshotRecursive);
|
||
}
|
||
};
|
||
|
||
_adjustParentTreeWeight = (
|
||
parentElement: Element | null,
|
||
weightDelta: number
|
||
) => {
|
||
let isInsideCollapsedSubTree = false;
|
||
|
||
while (parentElement != null) {
|
||
parentElement.weight += weightDelta;
|
||
|
||
// Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent.
|
||
// Their weight will bubble up when the parent is expanded.
|
||
if (parentElement.isCollapsed) {
|
||
isInsideCollapsedSubTree = true;
|
||
break;
|
||
}
|
||
|
||
parentElement = ((this._idToElement.get(
|
||
parentElement.parentID
|
||
): any): Element);
|
||
}
|
||
|
||
// Additions and deletions within a collapsed subtree should not affect the overall number of elements.
|
||
if (!isInsideCollapsedSubTree) {
|
||
this._weightAcrossRoots += weightDelta;
|
||
}
|
||
};
|
||
|
||
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));
|
||
}
|
||
|
||
if (__DEBUG__) {
|
||
console.groupCollapsed('onBridgeOperations');
|
||
debug('onBridgeOperations', operations.join(','));
|
||
}
|
||
|
||
let haveRootsChanged = false;
|
||
|
||
const rendererID = operations[0];
|
||
const rootID = operations[1];
|
||
|
||
if (this._isProfiling) {
|
||
let profilingOperations = this._profilingOperationsByRootID.get(rootID);
|
||
if (profilingOperations == null) {
|
||
profilingOperations = [operations];
|
||
this._profilingOperationsByRootID.set(rootID, profilingOperations);
|
||
} else {
|
||
profilingOperations.push(operations);
|
||
}
|
||
|
||
if (this._captureScreenshots) {
|
||
const commitIndex = profilingOperations.length - 1;
|
||
this._captureScreenshot(rootID, commitIndex);
|
||
}
|
||
}
|
||
|
||
const addedElementIDs: Array<number> = [];
|
||
// This is a mapping of removed ID -> parent ID:
|
||
const removedElementIDs: Map<number, number> = new Map();
|
||
// We'll use the parent ID to adjust selection if it gets deleted.
|
||
|
||
let i = 2;
|
||
|
||
// Reassemble the string table.
|
||
const stringTable = [
|
||
null, // ID = 0 corresponds to the null string.
|
||
];
|
||
const stringTableSize = operations[i++];
|
||
const stringTableEnd = i + stringTableSize;
|
||
while (i < stringTableEnd) {
|
||
const nextLength = operations[i++];
|
||
const nextString = utfDecodeString(
|
||
(operations.slice(i, i + nextLength): any)
|
||
);
|
||
stringTable.push(nextString);
|
||
i += nextLength;
|
||
}
|
||
|
||
while (i < operations.length) {
|
||
const operation = operations[i];
|
||
switch (operation) {
|
||
case TREE_OPERATION_ADD: {
|
||
const id = ((operations[i + 1]: any): number);
|
||
const type = ((operations[i + 2]: any): ElementType);
|
||
|
||
i = i + 3;
|
||
|
||
if (this._idToElement.has(id)) {
|
||
throw Error(
|
||
`Cannot add node ${id} because a node with that id is already in the Store.`
|
||
);
|
||
}
|
||
|
||
let ownerID: number = 0;
|
||
let parentID: number = ((null: any): number);
|
||
if (type === ElementTypeRoot) {
|
||
if (__DEBUG__) {
|
||
debug('Add', `new root node ${id}`);
|
||
}
|
||
|
||
const supportsProfiling = operations[i] > 0;
|
||
i++;
|
||
|
||
const hasOwnerMetadata = operations[i] > 0;
|
||
i++;
|
||
|
||
this._roots = this._roots.concat(id);
|
||
this._rootIDToRendererID.set(id, rendererID);
|
||
this._rootIDToCapabilities.set(id, {
|
||
hasOwnerMetadata,
|
||
supportsProfiling,
|
||
});
|
||
|
||
this._idToElement.set(id, {
|
||
children: [],
|
||
depth: -1,
|
||
displayName: null,
|
||
id,
|
||
isCollapsed: false, // Never collapse roots; it would hide the entire tree.
|
||
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 displayNameStringID = operations[i];
|
||
const displayName = stringTable[displayNameStringID];
|
||
i++;
|
||
|
||
const keyStringID = operations[i];
|
||
const key = stringTable[keyStringID];
|
||
i++;
|
||
|
||
if (__DEBUG__) {
|
||
debug(
|
||
'Add',
|
||
`node ${id} (${displayName || 'null'}) as child of ${parentID}`
|
||
);
|
||
}
|
||
|
||
if (!this._idToElement.has(parentID)) {
|
||
throw Error(
|
||
`Cannot add child ${id} to parent ${parentID} because parent node was not found in the Store.`
|
||
);
|
||
}
|
||
|
||
const parentElement = ((this._idToElement.get(
|
||
parentID
|
||
): any): Element);
|
||
parentElement.children.push(id);
|
||
|
||
const element: Element = {
|
||
children: [],
|
||
depth: parentElement.depth + 1,
|
||
displayName,
|
||
id,
|
||
isCollapsed: this._collapseNodesByDefault,
|
||
key,
|
||
ownerID,
|
||
parentID: parentElement.id,
|
||
type,
|
||
weight: 1,
|
||
};
|
||
|
||
this._idToElement.set(id, element);
|
||
addedElementIDs.push(id);
|
||
this._adjustParentTreeWeight(parentElement, 1);
|
||
|
||
if (ownerID > 0) {
|
||
let set = this._ownersMap.get(ownerID);
|
||
if (set === undefined) {
|
||
set = new Set();
|
||
this._ownersMap.set(ownerID, set);
|
||
}
|
||
set.add(id);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case TREE_OPERATION_REMOVE: {
|
||
const removeLength = ((operations[i + 1]: any): number);
|
||
i = i + 2;
|
||
|
||
for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) {
|
||
const id = ((operations[i]: any): number);
|
||
|
||
if (!this._idToElement.has(id)) {
|
||
throw Error(
|
||
`Cannot remove node ${id} because no matching node was found in the Store.`
|
||
);
|
||
}
|
||
|
||
i = i + 1;
|
||
|
||
const element = ((this._idToElement.get(id): any): Element);
|
||
const { children, ownerID, parentID, weight } = element;
|
||
if (children.length > 0) {
|
||
throw new Error(`Node ${id} was removed before its children.`);
|
||
}
|
||
|
||
// The following call depends on `getElementByID`
|
||
// which depends on the element being in `_idToElement`,
|
||
// so we have to do it before removing the element from `_idToElement`.
|
||
this._clearProfilingSnapshotRecursive(id);
|
||
|
||
this._idToElement.delete(id);
|
||
|
||
let parentElement = null;
|
||
if (parentID === 0) {
|
||
if (__DEBUG__) {
|
||
debug('Remove', `node ${id} root`);
|
||
}
|
||
|
||
this._roots = this._roots.filter(rootID => rootID !== id);
|
||
this._rootIDToRendererID.delete(id);
|
||
this._rootIDToCapabilities.delete(id);
|
||
|
||
this._profilingOperationsByRootID.delete(id);
|
||
this._profilingScreenshotsByRootID.delete(id);
|
||
|
||
haveRootsChanged = true;
|
||
} else {
|
||
if (__DEBUG__) {
|
||
debug('Remove', `node ${id} from parent ${parentID}`);
|
||
}
|
||
parentElement = ((this._idToElement.get(parentID): any): Element);
|
||
if (parentElement === undefined) {
|
||
throw Error(
|
||
`Cannot remove node ${id} from parent ${parentID} because no matching node was found in the Store.`
|
||
);
|
||
}
|
||
const index = parentElement.children.indexOf(id);
|
||
parentElement.children.splice(index, 1);
|
||
}
|
||
|
||
this._adjustParentTreeWeight(parentElement, -weight);
|
||
removedElementIDs.set(id, parentID);
|
||
|
||
this._ownersMap.delete(id);
|
||
if (ownerID > 0) {
|
||
const set = this._ownersMap.get(ownerID);
|
||
if (set !== undefined) {
|
||
set.delete(id);
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case TREE_OPERATION_REORDER_CHILDREN: {
|
||
const id = ((operations[i + 1]: any): number);
|
||
const numChildren = ((operations[i + 2]: any): number);
|
||
i = i + 3;
|
||
|
||
if (!this._idToElement.has(id)) {
|
||
throw Error(
|
||
`Cannot reorder children for node ${id} because no matching node was found in the Store.`
|
||
);
|
||
}
|
||
|
||
const element = ((this._idToElement.get(id): any): Element);
|
||
const children = element.children;
|
||
if (children.length !== numChildren) {
|
||
throw Error(
|
||
`Children cannot be added or removed during a reorder operation.`
|
||
);
|
||
}
|
||
|
||
for (let j = 0; j < numChildren; j++) {
|
||
const childID = operations[i + j];
|
||
children[j] = childID;
|
||
if (__DEV__) {
|
||
// This check is more expensive so it's gated by __DEV__.
|
||
const childElement = this._idToElement.get(childID);
|
||
if (childElement == null || childElement.parentID !== id) {
|
||
console.error(
|
||
`Children cannot be added or removed during a reorder operation.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
i = i + numChildren;
|
||
|
||
if (__DEBUG__) {
|
||
debug('Re-order', `Node ${id} children ${children.join(',')}`);
|
||
}
|
||
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._revision++;
|
||
|
||
if (haveRootsChanged) {
|
||
this._hasOwnerMetadata = false;
|
||
this._supportsProfiling = false;
|
||
this._rootIDToCapabilities.forEach(
|
||
({ hasOwnerMetadata, supportsProfiling }) => {
|
||
if (hasOwnerMetadata) {
|
||
this._hasOwnerMetadata = true;
|
||
}
|
||
if (supportsProfiling) {
|
||
this._supportsProfiling = true;
|
||
}
|
||
}
|
||
);
|
||
|
||
this.emit('roots');
|
||
}
|
||
|
||
if (__DEBUG__) {
|
||
console.log(printStore(this, true));
|
||
console.groupEnd();
|
||
}
|
||
|
||
this.emit('mutated', [addedElementIDs, removedElementIDs]);
|
||
};
|
||
|
||
onProfilingStatus = (isProfiling: boolean) => {
|
||
if (isProfiling) {
|
||
this._importedProfilingData = null;
|
||
this._profilingOperationsByRootID = new Map();
|
||
this._profilingScreenshotsByRootID = new Map();
|
||
this._profilingSnapshotsByElementID = new Map();
|
||
this.roots.forEach(this._takeProfilingSnapshotRecursive);
|
||
}
|
||
|
||
if (this._isProfiling !== isProfiling) {
|
||
this._isProfiling = isProfiling;
|
||
|
||
// 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.emit('isProfiling');
|
||
}
|
||
};
|
||
|
||
onScreenshotCaptured = ({
|
||
commitIndex,
|
||
dataURL,
|
||
rootID,
|
||
}: {|
|
||
commitIndex: number,
|
||
dataURL: string,
|
||
rootID: number,
|
||
|}) => {
|
||
let profilingScreenshotsForRootByCommitIndex = this._profilingScreenshotsByRootID.get(
|
||
rootID
|
||
);
|
||
if (!profilingScreenshotsForRootByCommitIndex) {
|
||
profilingScreenshotsForRootByCommitIndex = new Map();
|
||
this._profilingScreenshotsByRootID.set(
|
||
rootID,
|
||
profilingScreenshotsForRootByCommitIndex
|
||
);
|
||
}
|
||
profilingScreenshotsForRootByCommitIndex.set(commitIndex, dataURL);
|
||
};
|
||
|
||
onBridgeShutdown = () => {
|
||
if (__DEBUG__) {
|
||
debug('onBridgeShutdown', 'unsubscribing from Bridge');
|
||
}
|
||
|
||
this._bridge.removeListener('operations', this.onBridgeOperations);
|
||
this._bridge.removeListener('profilingStatus', this.onProfilingStatus);
|
||
this._bridge.removeListener('shutdown', this.onBridgeShutdown);
|
||
};
|
||
}
|