main
js 551 lines 16.9 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 type {ReactContext} from 'shared/ReactTypes';
11
12 import * as React from 'react';
13 import {
14 createContext,
15 useCallback,
16 useContext,
17 useDeferredValue,
18 useMemo,
19 useState,
20 useEffect,
21 } from 'react';
22 import {useLocalStorage, useSubscription} from '../hooks';
23 import {
24 TreeDispatcherContext,
25 TreeStateContext,
26 } from '../Components/TreeContext';
27 import {StoreContext} from '../context';
28 import {createRegExp} from '../utils';
29 import {logEvent} from 'react-devtools-shared/src/Logger';
30 import {useCommitFilteringAndNavigation} from './useCommitFilteringAndNavigation';
31
32 import type {
33 CommitDataFrontend,
34 CommitTree,
35 CommitTreeNode,
36 ProfilingDataFrontend,
37 } from './types';
38
39 export type TabID = 'flame-chart' | 'ranked-chart';
40
41 type SearchResult = {id: number, name: string | null};
42
43 function fiberMatchesQuery(node: CommitTreeNode, regExp: RegExp): boolean {
44 const {displayName, hocDisplayNames, key} = node;
45 return (
46 (displayName !== null && regExp.test(displayName)) ||
47 (hocDisplayNames !== null &&
48 hocDisplayNames.some(name => regExp.test(name))) ||
49 (key !== null && regExp.test(String(key)))
50 );
51 }
52
53 // Collect the fibers in a commit tree that match `text`, in tree (pre-order)
54 // order. Kept module-level and pure so it isn't recreated on every render.
55 function collectSearchMatches(
56 commitTree: CommitTree,
57 text: string,
58 ): Array<SearchResult> {
59 const regExp = createRegExp(text);
60 const matches: Array<SearchResult> = [];
61 const visit = (id: number) => {
62 const node = commitTree.nodes.get(id);
63 if (node == null) {
64 return;
65 }
66 if (fiberMatchesQuery(node, regExp)) {
67 matches.push({id, name: node.displayName});
68 }
69 node.children.forEach(visit);
70 };
71 visit(commitTree.rootID);
72 return matches;
73 }
74
75 export type Context = {
76 // Which tab is selected in the Profiler UI?
77 selectedTabID: TabID,
78 selectTab(id: TabID): void,
79
80 // Store subscription based values.
81 // The isProfiling value may be modified by the record button in the Profiler toolbar,
82 // or from the backend itself (after a reload-and-profile action).
83 // It is synced between the backend and frontend via a Store subscription.
84 didRecordCommits: boolean,
85 isProcessingData: boolean,
86 isProfiling: boolean,
87 profilingData: ProfilingDataFrontend | null,
88 startProfiling(): void,
89 stopProfiling(): void,
90 supportsProfiling: boolean,
91
92 // Which root should profiling data be shown for?
93 // This value should be initialized to either:
94 // 1. The selected root in the Components tree (if it has any profiling data) or
95 // 2. The first root in the list with profiling data.
96 rootID: number | null,
97 setRootID: (id: number) => void,
98
99 // Controls whether commits are filtered by duration.
100 // This value is controlled by a filter toggle UI in the Profiler toolbar.
101 // It impacts the commit selector UI as well as the fiber commits bar chart.
102 isCommitFilterEnabled: boolean,
103 setIsCommitFilterEnabled: (value: boolean) => void,
104 minCommitDuration: number,
105 setMinCommitDuration: (value: number) => void,
106
107 // Which commit is currently selected in the commit selector UI.
108 // Note that this is the index of the commit in all commits (non-filtered) that were profiled.
109 // This value is controlled by the commit selector UI in the Profiler toolbar.
110 // It impacts the flame graph and ranked charts.
111 selectedCommitIndex: number | null,
112 selectCommitIndex: (value: number | null) => void,
113 selectNextCommitIndex(): void,
114 selectPrevCommitIndex(): void,
115
116 // Which commits are currently filtered by duration?
117 filteredCommitIndices: Array<number>,
118 selectedFilteredCommitIndex: number | null,
119
120 // Which fiber is currently selected in the Ranked or Flamegraph charts?
121 selectedFiberID: number | null,
122 selectedFiberName: string | null,
123 selectFiber: (id: number | null, name: string | null) => void,
124
125 // Component search within the currently selected commit.
126 // Toggled by Cmd/Ctrl+F in the flame graph and ranked charts.
127 // Unlike the Components tab, results are scoped to the selected commit only.
128 isSearchInputVisible: boolean,
129 showSearchInput(): void,
130 hideSearchInput(): void,
131 searchText: string,
132 setSearchText: (text: string) => void,
133 searchResults: Array<SearchResult>,
134 searchIndex: number,
135 searchIsPending: boolean,
136 goToNextSearchResult(): void,
137 goToPreviousSearchResult(): void,
138 goToSearchResult: (index: number) => void,
139 };
140
141 const ProfilerContext: ReactContext<Context> = createContext<Context>(
142 null as any as Context,
143 );
144 ProfilerContext.displayName = 'ProfilerContext';
145
146 type StoreProfilingState = {
147 didRecordCommits: boolean,
148 isProcessingData: boolean,
149 isProfiling: boolean,
150 profilingData: ProfilingDataFrontend | null,
151 supportsProfiling: boolean,
152 };
153
154 type Props = {
155 children: React$Node,
156 };
157
158 function ProfilerContextController({children}: Props): React.Node {
159 const store = useContext(StoreContext);
160 const {inspectedElementID} = useContext(TreeStateContext);
161 const dispatch = useContext(TreeDispatcherContext);
162
163 const {profilerStore} = store;
164
165 const subscription = useMemo(
166 () => ({
167 getCurrentValue: () => ({
168 didRecordCommits: profilerStore.didRecordCommits,
169 isProcessingData: profilerStore.isProcessingData,
170 isProfiling: profilerStore.isProfilingBasedOnUserInput,
171 profilingData: profilerStore.profilingData,
172 supportsProfiling: store.rootSupportsBasicProfiling,
173 }),
174 subscribe: (callback: Function) => {
175 profilerStore.addListener('profilingData', callback);
176 profilerStore.addListener('isProcessingData', callback);
177 profilerStore.addListener('isProfiling', callback);
178 store.addListener('rootSupportsBasicProfiling', callback);
179 return () => {
180 profilerStore.removeListener('profilingData', callback);
181 profilerStore.removeListener('isProcessingData', callback);
182 profilerStore.removeListener('isProfiling', callback);
183 store.removeListener('rootSupportsBasicProfiling', callback);
184 };
185 },
186 }),
187 [profilerStore, store],
188 );
189 const {
190 didRecordCommits,
191 isProcessingData,
192 isProfiling,
193 profilingData,
194 supportsProfiling,
195 } = useSubscription<StoreProfilingState>(subscription);
196
197 const [prevProfilingData, setPrevProfilingData] =
198 useState<ProfilingDataFrontend | null>(null);
199 const [rootID, setRootID] = useState<number | null>(null);
200 const [selectedFiberID, selectFiberID] = useState<number | null>(null);
201 const [selectedFiberName, selectFiberName] = useState<string | null>(null);
202
203 // Component search (scoped to the currently selected commit).
204 const [isSearchInputVisible, setIsSearchInputVisible] =
205 useState<boolean>(false);
206 const [searchText, setSearchTextState] = useState<string>('');
207 const [searchIndex, setSearchIndex] = useState<number>(-1);
208
209 const selectFiber = useCallback(
210 (id: number | null, name: string | null) => {
211 selectFiberID(id);
212 selectFiberName(name);
213
214 // Sync selection to the Components tab for convenience.
215 // Keep in mind that profiling data may be from a previous session.
216 // If data has been imported, we should skip the selection sync.
217 if (
218 id !== null &&
219 profilingData !== null &&
220 profilingData.imported === false
221 ) {
222 // We should still check to see if this element is still in the store.
223 // It may have been removed during profiling.
224 if (store.containsElement(id)) {
225 dispatch({
226 type: 'SELECT_ELEMENT_BY_ID',
227 payload: id,
228 });
229 }
230 }
231 },
232 [dispatch, selectFiberID, selectFiberName, store, profilingData],
233 );
234
235 const setRootIDAndClearFiber = useCallback(
236 (id: number | null) => {
237 selectFiber(null, null);
238 setRootID(id);
239 },
240 [setRootID, selectFiber],
241 );
242
243 // Sync rootID with profilingData changes.
244 if (prevProfilingData !== profilingData) {
245 setPrevProfilingData(profilingData);
246
247 const dataForRoots =
248 profilingData !== null ? profilingData.dataForRoots : null;
249 if (dataForRoots != null) {
250 const firstRootID = dataForRoots.keys().next().value || null;
251
252 if (rootID === null || !dataForRoots.has(rootID)) {
253 let selectedElementRootID = null;
254 if (inspectedElementID !== null) {
255 selectedElementRootID = store.getRootIDForElement(inspectedElementID);
256 }
257 if (
258 selectedElementRootID !== null &&
259 dataForRoots.has(selectedElementRootID)
260 ) {
261 setRootIDAndClearFiber(selectedElementRootID);
262 } else {
263 setRootIDAndClearFiber(firstRootID);
264 }
265 }
266 }
267 }
268
269 const [persistedTabID, selectTab] = useLocalStorage<TabID>(
270 'React::DevTools::Profiler::defaultTab',
271 'flame-chart',
272 value => {
273 logEvent({
274 event_name: 'profiler-tab-changed',
275 metadata: {
276 tabId: value,
277 },
278 });
279 },
280 );
281
282 // The persisted value may name a tab that no longer exists,
283 // e.g. the removed "timeline" tab. Fall back rather than render nothing.
284 const selectedTabID: TabID =
285 persistedTabID === 'ranked-chart' ? persistedTabID : 'flame-chart';
286
287 const stopProfiling = useCallback(
288 () => store.profilerStore.stopProfiling(),
289 [store],
290 );
291
292 // Get commit data for the current root
293 // NOTE: Unlike profilerStore.getDataForRoot() which uses Suspense (throws when data unavailable),
294 // this uses subscription pattern and returns [] when data isn't ready.
295 // Always check didRecordCommits before using commitData or filteredCommitIndices.
296 const commitData = useMemo(() => {
297 if (!didRecordCommits || rootID === null || profilingData === null) {
298 return [] as Array<CommitDataFrontend>;
299 }
300 const dataForRoot = profilingData.dataForRoots.get(rootID);
301 return dataForRoot
302 ? dataForRoot.commitData
303 : ([] as Array<CommitDataFrontend>);
304 }, [didRecordCommits, rootID, profilingData]);
305
306 // Commit filtering and navigation
307 const {
308 isCommitFilterEnabled,
309 setIsCommitFilterEnabled,
310 minCommitDuration,
311 setMinCommitDuration,
312 selectedCommitIndex,
313 selectCommitIndex,
314 filteredCommitIndices,
315 selectedFilteredCommitIndex,
316 selectNextCommitIndex,
317 selectPrevCommitIndex,
318 } = useCommitFilteringAndNavigation(commitData);
319
320 // Fibers in the selected commit matching `text`, scoped to the current
321 // commit only (never the whole trace).
322 const findMatches = useCallback(
323 (text: string): Array<SearchResult> => {
324 if (
325 text === '' ||
326 rootID === null ||
327 selectedCommitIndex === null ||
328 !didRecordCommits
329 ) {
330 return [];
331 }
332 const commitTree = profilerStore.profilingCache.getCommitTree({
333 commitIndex: selectedCommitIndex,
334 rootID,
335 });
336 return collectSearchMatches(commitTree, text);
337 },
338 [rootID, selectedCommitIndex, didRecordCommits, profilerStore],
339 );
340
341 // Keep the controlled input update synchronous (see setSearchText), but
342 // derive matches from a *deferred* value so the tree walk runs at transition
343 // priority and never blocks typing. Deriving via a memo also keeps results
344 // scoped to the current commit for free (findMatches tracks selectedCommitIndex).
345 const deferredSearchText = useDeferredValue(searchText);
346 const searchIsPending = searchText !== deferredSearchText;
347 const searchResults = useMemo<Array<SearchResult>>(
348 () => findMatches(deferredSearchText),
349 [findMatches, deferredSearchText],
350 );
351
352 const setSearchText = useCallback((text: string) => {
353 // Synchronous so the input stays responsive; searchResults recomputes off
354 // the deferred value at transition priority.
355 setSearchTextState(text);
356 setSearchIndex(text === '' ? -1 : 0);
357 }, []);
358
359 const goToNextSearchResult = useCallback(() => {
360 setSearchIndex(prevIndex => {
361 const count = searchResults.length;
362 if (count === 0) {
363 return -1;
364 }
365 return prevIndex < 0 || prevIndex >= count ? 0 : (prevIndex + 1) % count;
366 });
367 }, [searchResults.length]);
368
369 const goToPreviousSearchResult = useCallback(() => {
370 setSearchIndex(prevIndex => {
371 const count = searchResults.length;
372 if (count === 0) {
373 return -1;
374 }
375 const current = prevIndex < 0 || prevIndex >= count ? count : prevIndex;
376 return current <= 0 ? count - 1 : current - 1;
377 });
378 }, [searchResults.length]);
379
380 const goToSearchResult = useCallback(
381 (index: number) => setSearchIndex(index),
382 [],
383 );
384
385 // Keep the selected fiber in sync with the current search match *during
386 // render* rather than in an effect, so results and selection commit together
387 // (no post-paint frame showing a stale/empty selection). This mirrors the
388 // existing prevProfilingData pattern above and follows
389 // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
390 // Note: only the profiler's own selection state is updated here (a render is
391 // not allowed to dispatch into the Components tree), so search navigation
392 // intentionally does not sync selection to the Components tab.
393 const [prevSearchResults, setPrevSearchResults] = useState(searchResults);
394 const [prevSearchIndex, setPrevSearchIndex] = useState(searchIndex);
395 if (prevSearchResults !== searchResults || prevSearchIndex !== searchIndex) {
396 setPrevSearchResults(searchResults);
397 setPrevSearchIndex(searchIndex);
398 if (searchText !== '') {
399 if (searchResults.length === 0) {
400 selectFiberID(null);
401 selectFiberName(null);
402 } else {
403 const index =
404 searchIndex < 0 || searchIndex >= searchResults.length
405 ? 0
406 : searchIndex;
407 const match = searchResults[index];
408 selectFiberID(match.id);
409 selectFiberName(match.name);
410 }
411 }
412 }
413
414 const showSearchInput = useCallback(() => setIsSearchInputVisible(true), []);
415
416 const hideSearchInput = useCallback(() => {
417 setIsSearchInputVisible(false);
418 setSearchTextState('');
419 setSearchIndex(-1);
420 }, []);
421
422 const startProfiling = useCallback(() => {
423 logEvent({
424 event_name: 'profiling-start',
425 metadata: {current_tab: selectedTabID},
426 });
427
428 // Clear selections when starting a new profiling session
429 selectCommitIndex(null);
430 selectFiberID(null);
431 selectFiberName(null);
432
433 // Clear any active search from the previous session.
434 setIsSearchInputVisible(false);
435 setSearchTextState('');
436 setSearchIndex(-1);
437
438 store.profilerStore.startProfiling();
439 }, [store, selectedTabID, selectCommitIndex]);
440
441 // Auto-select first commit when profiling data becomes available and no commit is selected.
442 useEffect(() => {
443 if (
444 profilingData !== null &&
445 selectedCommitIndex === null &&
446 rootID !== null
447 ) {
448 const dataForRoot = profilingData.dataForRoots.get(rootID);
449 if (dataForRoot && dataForRoot.commitData.length > 0) {
450 selectCommitIndex(0);
451 }
452 }
453 }, [profilingData, rootID, selectCommitIndex]);
454
455 const value = useMemo(
456 () => ({
457 selectedTabID,
458 selectTab,
459
460 didRecordCommits,
461 isProcessingData,
462 isProfiling,
463 profilingData,
464 startProfiling,
465 stopProfiling,
466 supportsProfiling,
467
468 rootID,
469 setRootID: setRootIDAndClearFiber,
470
471 isCommitFilterEnabled,
472 setIsCommitFilterEnabled,
473 minCommitDuration,
474 setMinCommitDuration,
475
476 selectedCommitIndex,
477 selectCommitIndex,
478 selectNextCommitIndex,
479 selectPrevCommitIndex,
480 filteredCommitIndices,
481 selectedFilteredCommitIndex,
482
483 selectedFiberID,
484 selectedFiberName,
485 selectFiber,
486
487 isSearchInputVisible,
488 showSearchInput,
489 hideSearchInput,
490 searchText,
491 setSearchText,
492 searchResults,
493 searchIndex,
494 searchIsPending,
495 goToNextSearchResult,
496 goToPreviousSearchResult,
497 goToSearchResult,
498 }),
499 [
500 selectedTabID,
501 selectTab,
502
503 didRecordCommits,
504 isProcessingData,
505 isProfiling,
506 profilingData,
507 startProfiling,
508 stopProfiling,
509 supportsProfiling,
510
511 rootID,
512 setRootIDAndClearFiber,
513
514 isCommitFilterEnabled,
515 setIsCommitFilterEnabled,
516 minCommitDuration,
517 setMinCommitDuration,
518
519 selectedCommitIndex,
520 selectCommitIndex,
521 selectNextCommitIndex,
522 selectPrevCommitIndex,
523 filteredCommitIndices,
524 selectedFilteredCommitIndex,
525
526 selectedFiberID,
527 selectedFiberName,
528 selectFiber,
529
530 isSearchInputVisible,
531 showSearchInput,
532 hideSearchInput,
533 searchText,
534 setSearchText,
535 searchResults,
536 searchIndex,
537 searchIsPending,
538 goToNextSearchResult,
539 goToPreviousSearchResult,
540 goToSearchResult,
541 ],
542 );
543
544 return (
545 <ProfilerContext.Provider value={value}>
546 {children}
547 </ProfilerContext.Provider>
548 );
549 }
550
551 export {ProfilerContext, ProfilerContextController};