main
js 380 lines 13.8 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import EventEmitter from '../events';
11 import {prepareProfilingDataFrontendFromBackendAndStore} from './views/Profiler/utils';
12 import ProfilingCache from './ProfilingCache';
13 import Store from './store';
14 import {logEvent} from 'react-devtools-shared/src/Logger';
15
16 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
17 import type {ProfilingDataBackend} from 'react-devtools-shared/src/backend/types';
18 import type {
19 CommitDataFrontend,
20 ProfilingDataForRootFrontend,
21 ProfilingDataFrontend,
22 SnapshotNode,
23 } from './views/Profiler/types';
24
25 export default class ProfilerStore extends EventEmitter<{
26 isProcessingData: [],
27 isProfiling: [],
28 profilingData: [],
29 }> {
30 _bridge: FrontendBridge;
31
32 // Suspense cache for lazily calculating derived profiling data.
33 _cache: ProfilingCache;
34
35 // Temporary store of profiling data from the backend renderer(s).
36 // This data will be converted to the ProfilingDataFrontend format after being collected from all renderers.
37 _dataBackends: Array<ProfilingDataBackend> = [];
38
39 // Data from the most recently completed profiling session,
40 // or data that has been imported from a previously exported session.
41 // This object contains all necessary data to drive the Profiler UI interface,
42 // even though some of it is lazily parsed/derived via the ProfilingCache.
43 _dataFrontend: ProfilingDataFrontend | null = null;
44
45 // Snapshot of all attached renderer IDs.
46 // Once profiling is finished, this snapshot will be used to query renderers for profiling data.
47 //
48 // This map is initialized when profiling starts and updated when a new root is added while profiling;
49 // Upon completion, it is converted into the exportable ProfilingDataFrontend format.
50 _initialRendererIDs: Set<number> = new Set();
51
52 // Snapshot of the state of the main Store (including all roots) when profiling started.
53 // Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling,
54 // to reconstruct the state of each root for each commit.
55 // It's okay to use a single root to store this information because node IDs are unique across all roots.
56 //
57 // This map is initialized when profiling starts and updated when a new root is added while profiling;
58 // Upon completion, it is converted into the exportable ProfilingDataFrontend format.
59 _initialSnapshotsByRootID: Map<number, Map<number, SnapshotNode>> = new Map();
60
61 // Map of root (id) to a list of tree mutation that occur during profiling.
62 // Once profiling is finished, these mutations can be used, along with the initial tree snapshots,
63 // to reconstruct the state of each root for each commit.
64 //
65 // This map is only updated while profiling is in progress;
66 // Upon completion, it is converted into the exportable ProfilingDataFrontend format.
67 _inProgressOperationsByRootID: Map<number, Array<Array<number>>> = new Map();
68
69 // The backend is currently profiling.
70 // When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
71 _isBackendProfiling: boolean = false;
72
73 // Mainly used for optimistic UI.
74 // This could be false, but at the same time _isBackendProfiling could be true
75 // for cases when Backend is busy serializing a chunky payload.
76 _isProfilingBasedOnUserInput: boolean = false;
77
78 // Tracks whether a specific renderer logged any profiling data during the most recent session.
79 _rendererIDsThatReportedProfilingData: Set<number> = new Set();
80
81 // After profiling, data is requested from each attached renderer using this queue.
82 // So long as this queue is not empty, the store is retrieving and processing profiling data from the backend.
83 _rendererQueue: Set<number> = new Set();
84
85 _store: Store;
86
87 constructor(
88 bridge: FrontendBridge,
89 store: Store,
90 defaultIsProfiling: boolean,
91 ) {
92 super();
93
94 this._bridge = bridge;
95 this._isBackendProfiling = defaultIsProfiling;
96 this._isProfilingBasedOnUserInput = defaultIsProfiling;
97 this._store = store;
98
99 bridge.addListener('operations', this.onBridgeOperations);
100 bridge.addListener('profilingData', this.onBridgeProfilingData);
101 bridge.addListener('profilingStatus', this.onProfilingStatus);
102 bridge.addListener('shutdown', this.onBridgeShutdown);
103
104 // It's possible that profiling has already started (e.g. "reload and start profiling")
105 // so the frontend needs to ask the backend for its status after mounting.
106 bridge.send('getProfilingStatus');
107
108 this._cache = new ProfilingCache(this);
109 }
110
111 getCommitData(rootID: number, commitIndex: number): CommitDataFrontend {
112 if (this._dataFrontend !== null) {
113 const dataForRoot = this._dataFrontend.dataForRoots.get(rootID);
114 if (dataForRoot != null) {
115 const commitDatum = dataForRoot.commitData[commitIndex];
116 if (commitDatum != null) {
117 return commitDatum;
118 }
119 }
120 }
121
122 throw Error(
123 `Could not find commit data for root "${rootID}" and commit "${commitIndex}"`,
124 );
125 }
126
127 getDataForRoot(rootID: number): ProfilingDataForRootFrontend {
128 if (this._dataFrontend !== null) {
129 const dataForRoot = this._dataFrontend.dataForRoots.get(rootID);
130 if (dataForRoot != null) {
131 return dataForRoot;
132 }
133 }
134
135 throw Error(`Could not find commit data for root "${rootID}"`);
136 }
137
138 // Profiling data has been recorded for at least one root.
139 get didRecordCommits(): boolean {
140 return (
141 this._dataFrontend !== null && this._dataFrontend.dataForRoots.size > 0
142 );
143 }
144
145 get isProcessingData(): boolean {
146 return this._rendererQueue.size > 0 || this._dataBackends.length > 0;
147 }
148
149 get isProfilingBasedOnUserInput(): boolean {
150 return this._isProfilingBasedOnUserInput;
151 }
152
153 get profilingCache(): ProfilingCache {
154 return this._cache;
155 }
156
157 get profilingData(): ProfilingDataFrontend | null {
158 return this._dataFrontend;
159 }
160 set profilingData(value: ProfilingDataFrontend | null): void {
161 if (this._isBackendProfiling) {
162 console.warn(
163 'Profiling data cannot be updated while profiling is in progress.',
164 );
165 return;
166 }
167
168 this._dataBackends.splice(0);
169 this._dataFrontend = value;
170 this._initialRendererIDs.clear();
171 this._initialSnapshotsByRootID.clear();
172 this._inProgressOperationsByRootID.clear();
173 this._cache.invalidate();
174
175 this.emit('profilingData');
176 }
177
178 clear(): void {
179 this._dataBackends.splice(0);
180 this._dataFrontend = null;
181 this._initialRendererIDs.clear();
182 this._initialSnapshotsByRootID.clear();
183 this._inProgressOperationsByRootID.clear();
184 this._rendererQueue.clear();
185
186 // Invalidate suspense cache if profiling data is being (re-)recorded.
187 // Note that we clear now because any existing data is "stale".
188 this._cache.invalidate();
189
190 this.emit('profilingData');
191 }
192
193 startProfiling(): void {
194 this.clear();
195
196 this._bridge.send('startProfiling', {
197 recordChangeDescriptions: this._store.recordChangeDescriptions,
198 });
199
200 this._isProfilingBasedOnUserInput = true;
201 this.emit('isProfiling');
202
203 // Don't actually update the local profiling boolean yet!
204 // Wait for onProfilingStatus() to confirm the status has changed.
205 // This ensures the frontend and backend are in sync wrt which commits were profiled.
206 // We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors.
207 }
208
209 stopProfiling(): void {
210 this._bridge.send('stopProfiling');
211
212 // Backend might be busy serializing the payload, so we are going to display
213 // optimistic UI to the user that profiling is stopping.
214 this._isProfilingBasedOnUserInput = false;
215 this.emit('isProfiling');
216
217 // Wait for onProfilingStatus() to confirm the status has changed, this will update _isBackendProfiling.
218 // This ensures the frontend and backend are in sync wrt which commits were profiled.
219 // We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors.
220 }
221
222 _takeProfilingSnapshotRecursive: (
223 elementID: number,
224 profilingSnapshots: Map<number, SnapshotNode>,
225 ) => void = (elementID, profilingSnapshots) => {
226 const element = this._store.getElementByID(elementID);
227 if (element !== null) {
228 const snapshotNode: SnapshotNode = {
229 id: elementID,
230 children: element.children.slice(0),
231 displayName: element.displayName,
232 hocDisplayNames: element.hocDisplayNames,
233 key: element.key,
234 type: element.type,
235 compiledWithForget: element.compiledWithForget,
236 };
237 profilingSnapshots.set(elementID, snapshotNode);
238
239 element.children.forEach(childID =>
240 this._takeProfilingSnapshotRecursive(childID, profilingSnapshots),
241 );
242 }
243 };
244
245 onBridgeOperations: (operations: Array<number>) => void = operations => {
246 // The first two values are always rendererID and rootID
247 const rendererID = operations[0];
248 const rootID = operations[1];
249
250 if (this._isBackendProfiling) {
251 let profilingOperations = this._inProgressOperationsByRootID.get(rootID);
252 if (profilingOperations == null) {
253 profilingOperations = [operations];
254 this._inProgressOperationsByRootID.set(rootID, profilingOperations);
255 } else {
256 profilingOperations.push(operations);
257 }
258
259 if (!this._initialRendererIDs.has(rendererID)) {
260 this._initialRendererIDs.add(rendererID);
261 }
262
263 if (!this._initialSnapshotsByRootID.has(rootID)) {
264 this._initialSnapshotsByRootID.set(rootID, new Map());
265 }
266
267 this._rendererIDsThatReportedProfilingData.add(rendererID);
268 }
269 };
270
271 onBridgeProfilingData: (dataBackend: ProfilingDataBackend) => void =
272 dataBackend => {
273 if (this._isBackendProfiling) {
274 // This should never happen, but if it does, then ignore previous profiling data.
275 return;
276 }
277
278 const {rendererID} = dataBackend;
279
280 if (!this._rendererQueue.has(rendererID)) {
281 throw Error(
282 `Unexpected profiling data update from renderer "${rendererID}"`,
283 );
284 }
285
286 this._dataBackends.push(dataBackend);
287 this._rendererQueue.delete(rendererID);
288
289 if (this._rendererQueue.size === 0) {
290 this._dataFrontend = prepareProfilingDataFrontendFromBackendAndStore(
291 this._dataBackends,
292 this._inProgressOperationsByRootID,
293 this._initialSnapshotsByRootID,
294 );
295
296 this._dataBackends.splice(0);
297
298 this.emit('isProcessingData');
299 }
300 };
301
302 onBridgeShutdown: () => void = () => {
303 this._bridge.removeListener('operations', this.onBridgeOperations);
304 this._bridge.removeListener('profilingData', this.onBridgeProfilingData);
305 this._bridge.removeListener('profilingStatus', this.onProfilingStatus);
306 this._bridge.removeListener('shutdown', this.onBridgeShutdown);
307 };
308
309 onProfilingStatus: (isProfiling: boolean) => void = isProfiling => {
310 if (this._isBackendProfiling === isProfiling) {
311 return;
312 }
313
314 if (isProfiling) {
315 this._dataBackends.splice(0);
316 this._dataFrontend = null;
317 this._initialRendererIDs.clear();
318 this._initialSnapshotsByRootID.clear();
319 this._inProgressOperationsByRootID.clear();
320 this._rendererIDsThatReportedProfilingData.clear();
321 this._rendererQueue.clear();
322
323 // Record all renderer IDs initially too (in case of unmount)
324 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
325 for (const rendererID of this._store.rootIDToRendererID.values()) {
326 if (!this._initialRendererIDs.has(rendererID)) {
327 this._initialRendererIDs.add(rendererID);
328 }
329 }
330
331 // Record snapshot of tree at the time profiling is started.
332 // This info is required to handle cases of e.g. nodes being removed during profiling.
333 this._store.roots.forEach(rootID => {
334 const profilingSnapshots = new Map<number, SnapshotNode>();
335 this._initialSnapshotsByRootID.set(rootID, profilingSnapshots);
336 this._takeProfilingSnapshotRecursive(rootID, profilingSnapshots);
337 });
338 }
339
340 this._isBackendProfiling = isProfiling;
341 // _isProfilingBasedOnUserInput should already be updated from startProfiling, stopProfiling, or constructor.
342 if (this._isProfilingBasedOnUserInput !== isProfiling) {
343 logEvent({
344 event_name: 'error',
345 error_message: `Unexpected profiling status. Expected ${this._isProfilingBasedOnUserInput.toString()}, but received ${isProfiling.toString()}.`,
346 error_stack: new Error().stack,
347 error_component_stack: null,
348 });
349
350 // If happened, fallback to displaying the value from Backend
351 this._isProfilingBasedOnUserInput = isProfiling;
352 }
353
354 // Invalidate suspense cache if profiling data is being (re-)recorded.
355 // Note that we clear again, in case any views read from the cache while profiling.
356 // (That would have resolved a now-stale value without any profiling data.)
357 this._cache.invalidate();
358
359 // If we've just finished a profiling session, we need to fetch data stored in each renderer interface
360 // and re-assemble it on the front-end into a format (ProfilingDataFrontend) that can power the Profiler UI.
361 // During this time, DevTools UI should probably not be interactive.
362 if (!isProfiling) {
363 this._dataBackends.splice(0);
364 this._rendererQueue.clear();
365
366 // Only request data from renderers that actually logged it.
367 // This avoids unnecessary bridge requests and also avoids edge case mixed renderer bugs.
368 // (e.g. when v15 and v16 are both present)
369 this._rendererIDsThatReportedProfilingData.forEach(rendererID => {
370 if (!this._rendererQueue.has(rendererID)) {
371 this._rendererQueue.add(rendererID);
372
373 this._bridge.send('getProfilingData', {rendererID});
374 }
375 });
376
377 this.emit('isProcessingData');
378 }
379 };
380 }