| 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}; |