/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import EventEmitter from '../events';
import {prepareProfilingDataFrontendFromBackendAndStore} from './views/Profiler/utils';
import ProfilingCache from './ProfilingCache';
import Store from './store';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {ProfilingDataBackend} from 'react-devtools-shared/src/backend/types';
import type {
CommitDataFrontend,
ProfilingDataForRootFrontend,
ProfilingDataFrontend,
SnapshotNode,
} from './views/Profiler/types';
export default class ProfilerStore extends EventEmitter<{
isProcessingData: [],
isProfiling: [],
profilingData: [],
}> {
_bridge: FrontendBridge;
// Suspense cache for lazily calculating derived profiling data.
_cache: ProfilingCache;
// Temporary store of profiling data from the backend renderer(s).
// This data will be converted to the ProfilingDataFrontend format after being collected from all renderers.
_dataBackends: Array<ProfilingDataBackend> = [];
// Data from the most recently completed profiling session,
// or data that has been imported from a previously exported session.
// This object contains all necessary data to drive the Profiler UI interface,
// even though some of it is lazily parsed/derived via the ProfilingCache.
_dataFrontend: ProfilingDataFrontend | null = null;
// Snapshot of all attached renderer IDs.
// Once profiling is finished, this snapshot will be used to query renderers for profiling data.
//
// This map is initialized when profiling starts and updated when a new root is added while profiling;
// Upon completion, it is converted into the exportable ProfilingDataFrontend format.
_initialRendererIDs: Set<number> = new Set();
// 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.
//
// This map is initialized when profiling starts and updated when a new root is added while profiling;
// Upon completion, it is converted into the exportable ProfilingDataFrontend format.
_initialSnapshotsByRootID: Map<number, Map<number, SnapshotNode>> = new Map();
// 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.
//
// This map is only updated while profiling is in progress;
// Upon completion, it is converted into the exportable ProfilingDataFrontend format.
_inProgressOperationsByRootID: Map<number, Array<Array<number>>> = 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;
// Tracks whether a specific renderer logged any profiling data during the most recent session.
_rendererIDsThatReportedProfilingData: Set<number> = new Set();
// After profiling, data is requested from each attached renderer using this queue.
// So long as this queue is not empty, the store is retrieving and processing profiling data from the backend.
_rendererQueue: Set<number> = new Set();
_store: Store;
constructor(
bridge: FrontendBridge,
store: Store,
defaultIsProfiling: boolean,
) {
super();
this._bridge = bridge;
this._isProfiling = defaultIsProfiling;
this._store = store;
bridge.addListener('operations', this.onBridgeOperations);
bridge.addListener('profilingData', this.onBridgeProfilingData);
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._cache = new ProfilingCache(this);
}
getCommitData(rootID: number, commitIndex: number): CommitDataFrontend {
if (this._dataFrontend !== null) {
const dataForRoot = this._dataFrontend.dataForRoots.get(rootID);
if (dataForRoot != null) {
const commitDatum = dataForRoot.commitData[commitIndex];
if (commitDatum != null) {
return commitDatum;
}
}
}
throw Error(
`Could not find commit data for root "${rootID}" and commit "${commitIndex}"`,
);
}
getDataForRoot(rootID: number): ProfilingDataForRootFrontend {
if (this._dataFrontend !== null) {
const dataForRoot = this._dataFrontend.dataForRoots.get(rootID);
if (dataForRoot != null) {
return dataForRoot;
}
}
throw Error(`Could not find commit data for root "${rootID}"`);
}
// Profiling data has been recorded for at least one root.
get didRecordCommits(): boolean {
return (
this._dataFrontend !== null && this._dataFrontend.dataForRoots.size > 0
);
}
get isProcessingData(): boolean {
return this._rendererQueue.size > 0 || this._dataBackends.length > 0;
}
get isProfiling(): boolean {
return this._isProfiling;
}
get profilingCache(): ProfilingCache {
return this._cache;
}
get profilingData(): ProfilingDataFrontend | null {
return this._dataFrontend;
}
set profilingData(value: ProfilingDataFrontend | null): void {
if (this._isProfiling) {
console.warn(
'Profiling data cannot be updated while profiling is in progress.',
);
return;
}
this._dataBackends.splice(0);
this._dataFrontend = value;
this._initialRendererIDs.clear();
this._initialSnapshotsByRootID.clear();
this._inProgressOperationsByRootID.clear();
this._cache.invalidate();
this.emit('profilingData');
}
clear(): void {
this._dataBackends.splice(0);
this._dataFrontend = null;
this._initialRendererIDs.clear();
this._initialSnapshotsByRootID.clear();
this._inProgressOperationsByRootID.clear();
this._rendererQueue.clear();
// Invalidate suspense cache if profiling data is being (re-)recorded.
// Note that we clear now because any existing data is "stale".
this._cache.invalidate();
this.emit('profilingData');
}
startProfiling(): void {
this._bridge.send('startProfiling', this._store.recordChangeDescriptions);
// 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.
}
_takeProfilingSnapshotRecursive: (
elementID: number,
profilingSnapshots: Map<number, SnapshotNode>,
) => void = (elementID, profilingSnapshots) => {
const element = this._store.getElementByID(elementID);
if (element !== null) {
const snapshotNode: SnapshotNode = {
id: elementID,
children: element.children.slice(0),
displayName: element.displayName,
hocDisplayNames: element.hocDisplayNames,
key: element.key,
type: element.type,
};
profilingSnapshots.set(elementID, snapshotNode);
element.children.forEach(childID =>
this._takeProfilingSnapshotRecursive(childID, profilingSnapshots),
);
}
};
onBridgeOperations: (operations: Array<number>) => void = operations => {
// The first two values are always rendererID and rootID
const rendererID = operations[0];
const rootID = operations[1];
if (this._isProfiling) {
let profilingOperations = this._inProgressOperationsByRootID.get(rootID);
if (profilingOperations == null) {
profilingOperations = [operations];
this._inProgressOperationsByRootID.set(rootID, profilingOperations);
} else {
profilingOperations.push(operations);
}
if (!this._initialRendererIDs.has(rendererID)) {
this._initialRendererIDs.add(rendererID);
}
if (!this._initialSnapshotsByRootID.has(rootID)) {
this._initialSnapshotsByRootID.set(rootID, new Map());
}
this._rendererIDsThatReportedProfilingData.add(rendererID);
}
};
onBridgeProfilingData: (dataBackend: ProfilingDataBackend) => void =
dataBackend => {
if (this._isProfiling) {
// This should never happen, but if it does- ignore previous profiling data.
return;
}
const {rendererID} = dataBackend;
if (!this._rendererQueue.has(rendererID)) {
throw Error(
`Unexpected profiling data update from renderer "${rendererID}"`,
);
}
this._dataBackends.push(dataBackend);
this._rendererQueue.delete(rendererID);
if (this._rendererQueue.size === 0) {
this._dataFrontend = prepareProfilingDataFrontendFromBackendAndStore(
this._dataBackends,
this._inProgressOperationsByRootID,
this._initialSnapshotsByRootID,
);
this._dataBackends.splice(0);
this.emit('isProcessingData');
}
};
onBridgeShutdown: () => void = () => {
this._bridge.removeListener('operations', this.onBridgeOperations);
this._bridge.removeListener('profilingData', this.onBridgeProfilingData);
this._bridge.removeListener('profilingStatus', this.onProfilingStatus);
this._bridge.removeListener('shutdown', this.onBridgeShutdown);
};
onProfilingStatus: (isProfiling: boolean) => void = isProfiling => {
if (isProfiling) {
this._dataBackends.splice(0);
this._dataFrontend = null;
this._initialRendererIDs.clear();
this._initialSnapshotsByRootID.clear();
this._inProgressOperationsByRootID.clear();
this._rendererIDsThatReportedProfilingData.clear();
this._rendererQueue.clear();
// Record all renderer IDs initially too (in case of unmount)
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const rendererID of this._store.rootIDToRendererID.values()) {
if (!this._initialRendererIDs.has(rendererID)) {
this._initialRendererIDs.add(rendererID);
}
}
// Record snapshot of tree at the time profiling is started.
// This info is required to handle cases of e.g. nodes being removed during profiling.
this._store.roots.forEach(rootID => {
const profilingSnapshots = new Map<number, SnapshotNode>();
this._initialSnapshotsByRootID.set(rootID, profilingSnapshots);
this._takeProfilingSnapshotRecursive(rootID, profilingSnapshots);
});
}
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._cache.invalidate();
this.emit('isProfiling');
// If we've just finished a profiling session, we need to fetch data stored in each renderer interface
// and re-assemble it on the front-end into a format (ProfilingDataFrontend) that can power the Profiler UI.
// During this time, DevTools UI should probably not be interactive.
if (!isProfiling) {
this._dataBackends.splice(0);
this._rendererQueue.clear();
// Only request data from renderers that actually logged it.
// This avoids unnecessary bridge requests and also avoids edge case mixed renderer bugs.
// (e.g. when v15 and v16 are both present)
this._rendererIDsThatReportedProfilingData.forEach(rendererID => {
if (!this._rendererQueue.has(rendererID)) {
this._rendererQueue.add(rendererID);
this._bridge.send('getProfilingData', {rendererID});
}
});
this.emit('isProcessingData');
}
}
};
}