ProfilerContext.js552 lines
mainRaw/**
* 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 type {ReactContext} from 'shared/ReactTypes';
import * as React from 'react';
import {
createContext,
useCallback,
useContext,
useDeferredValue,
useMemo,
useState,
useEffect,
} from 'react';
import {useLocalStorage, useSubscription} from '../hooks';
import {
TreeDispatcherContext,
TreeStateContext,
} from '../Components/TreeContext';
import {StoreContext} from '../context';
import {createRegExp} from '../utils';
import {logEvent} from 'react-devtools-shared/src/Logger';
import {useCommitFilteringAndNavigation} from './useCommitFilteringAndNavigation';
import type {
CommitDataFrontend,
CommitTree,
CommitTreeNode,
ProfilingDataFrontend,
} from './types';
export type TabID = 'flame-chart' | 'ranked-chart';
type SearchResult = {id: number, name: string | null};
function fiberMatchesQuery(node: CommitTreeNode, regExp: RegExp): boolean {
const {displayName, hocDisplayNames, key} = node;
return (
(displayName !== null && regExp.test(displayName)) ||
(hocDisplayNames !== null &&
hocDisplayNames.some(name => regExp.test(name))) ||
(key !== null && regExp.test(String(key)))
);
}
// Collect the fibers in a commit tree that match `text`, in tree (pre-order)
// order. Kept module-level and pure so it isn't recreated on every render.
function collectSearchMatches(
commitTree: CommitTree,
text: string,
): Array<SearchResult> {
const regExp = createRegExp(text);
const matches: Array<SearchResult> = [];
const visit = (id: number) => {
const node = commitTree.nodes.get(id);
if (node == null) {
return;
}
if (fiberMatchesQuery(node, regExp)) {
matches.push({id, name: node.displayName});
}
node.children.forEach(visit);
};
visit(commitTree.rootID);
return matches;
}
export type Context = {
// Which tab is selected in the Profiler UI?
selectedTabID: TabID,
selectTab(id: TabID): void,
// Store subscription based values.
// The isProfiling value may be modified by the record button in the Profiler toolbar,
// or from the backend itself (after a reload-and-profile action).
// It is synced between the backend and frontend via a Store subscription.
didRecordCommits: boolean,
isProcessingData: boolean,
isProfiling: boolean,
profilingData: ProfilingDataFrontend | null,
startProfiling(): void,
stopProfiling(): void,
supportsProfiling: boolean,
// Which root should profiling data be shown for?
// This value should be initialized to either:
// 1. The selected root in the Components tree (if it has any profiling data) or
// 2. The first root in the list with profiling data.
rootID: number | null,
setRootID: (id: number) => void,
// Controls whether commits are filtered by duration.
// This value is controlled by a filter toggle UI in the Profiler toolbar.
// It impacts the commit selector UI as well as the fiber commits bar chart.
isCommitFilterEnabled: boolean,
setIsCommitFilterEnabled: (value: boolean) => void,
minCommitDuration: number,
setMinCommitDuration: (value: number) => void,
// Which commit is currently selected in the commit selector UI.
// Note that this is the index of the commit in all commits (non-filtered) that were profiled.
// This value is controlled by the commit selector UI in the Profiler toolbar.
// It impacts the flame graph and ranked charts.
selectedCommitIndex: number | null,
selectCommitIndex: (value: number | null) => void,
selectNextCommitIndex(): void,
selectPrevCommitIndex(): void,
// Which commits are currently filtered by duration?
filteredCommitIndices: Array<number>,
selectedFilteredCommitIndex: number | null,
// Which fiber is currently selected in the Ranked or Flamegraph charts?
selectedFiberID: number | null,
selectedFiberName: string | null,
selectFiber: (id: number | null, name: string | null) => void,
// Component search within the currently selected commit.
// Toggled by Cmd/Ctrl+F in the flame graph and ranked charts.
// Unlike the Components tab, results are scoped to the selected commit only.
isSearchInputVisible: boolean,
showSearchInput(): void,
hideSearchInput(): void,
searchText: string,
setSearchText: (text: string) => void,
searchResults: Array<SearchResult>,
searchIndex: number,
searchIsPending: boolean,
goToNextSearchResult(): void,
goToPreviousSearchResult(): void,
goToSearchResult: (index: number) => void,
};
const ProfilerContext: ReactContext<Context> = createContext<Context>(
null as any as Context,
);
ProfilerContext.displayName = 'ProfilerContext';
type StoreProfilingState = {
didRecordCommits: boolean,
isProcessingData: boolean,
isProfiling: boolean,
profilingData: ProfilingDataFrontend | null,
supportsProfiling: boolean,
};
type Props = {
children: React$Node,
};
function ProfilerContextController({children}: Props): React.Node {
const store = useContext(StoreContext);
const {inspectedElementID} = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatcherContext);
const {profilerStore} = store;
const subscription = useMemo(
() => ({
getCurrentValue: () => ({
didRecordCommits: profilerStore.didRecordCommits,
isProcessingData: profilerStore.isProcessingData,
isProfiling: profilerStore.isProfilingBasedOnUserInput,
profilingData: profilerStore.profilingData,
supportsProfiling: store.rootSupportsBasicProfiling,
}),
subscribe: (callback: Function) => {
profilerStore.addListener('profilingData', callback);
profilerStore.addListener('isProcessingData', callback);
profilerStore.addListener('isProfiling', callback);
store.addListener('rootSupportsBasicProfiling', callback);
return () => {
profilerStore.removeListener('profilingData', callback);
profilerStore.removeListener('isProcessingData', callback);
profilerStore.removeListener('isProfiling', callback);
store.removeListener('rootSupportsBasicProfiling', callback);
};
},
}),
[profilerStore, store],
);
const {
didRecordCommits,
isProcessingData,
isProfiling,
profilingData,
supportsProfiling,
} = useSubscription<StoreProfilingState>(subscription);
const [prevProfilingData, setPrevProfilingData] =
useState<ProfilingDataFrontend | null>(null);
const [rootID, setRootID] = useState<number | null>(null);
const [selectedFiberID, selectFiberID] = useState<number | null>(null);
const [selectedFiberName, selectFiberName] = useState<string | null>(null);
// Component search (scoped to the currently selected commit).
const [isSearchInputVisible, setIsSearchInputVisible] =
useState<boolean>(false);
const [searchText, setSearchTextState] = useState<string>('');
const [searchIndex, setSearchIndex] = useState<number>(-1);
const selectFiber = useCallback(
(id: number | null, name: string | null) => {
selectFiberID(id);
selectFiberName(name);
// Sync selection to the Components tab for convenience.
// Keep in mind that profiling data may be from a previous session.
// If data has been imported, we should skip the selection sync.
if (
id !== null &&
profilingData !== null &&
profilingData.imported === false
) {
// We should still check to see if this element is still in the store.
// It may have been removed during profiling.
if (store.containsElement(id)) {
dispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: id,
});
}
}
},
[dispatch, selectFiberID, selectFiberName, store, profilingData],
);
const setRootIDAndClearFiber = useCallback(
(id: number | null) => {
selectFiber(null, null);
setRootID(id);
},
[setRootID, selectFiber],
);
// Sync rootID with profilingData changes.
if (prevProfilingData !== profilingData) {
setPrevProfilingData(profilingData);
const dataForRoots =
profilingData !== null ? profilingData.dataForRoots : null;
if (dataForRoots != null) {
const firstRootID = dataForRoots.keys().next().value || null;
if (rootID === null || !dataForRoots.has(rootID)) {
let selectedElementRootID = null;
if (inspectedElementID !== null) {
selectedElementRootID = store.getRootIDForElement(inspectedElementID);
}
if (
selectedElementRootID !== null &&
dataForRoots.has(selectedElementRootID)
) {
setRootIDAndClearFiber(selectedElementRootID);
} else {
setRootIDAndClearFiber(firstRootID);
}
}
}
}
const [persistedTabID, selectTab] = useLocalStorage<TabID>(
'React::DevTools::Profiler::defaultTab',
'flame-chart',
value => {
logEvent({
event_name: 'profiler-tab-changed',
metadata: {
tabId: value,
},
});
},
);
// The persisted value may name a tab that no longer exists,
// e.g. the removed "timeline" tab. Fall back rather than render nothing.
const selectedTabID: TabID =
persistedTabID === 'ranked-chart' ? persistedTabID : 'flame-chart';
const stopProfiling = useCallback(
() => store.profilerStore.stopProfiling(),
[store],
);
// Get commit data for the current root
// NOTE: Unlike profilerStore.getDataForRoot() which uses Suspense (throws when data unavailable),
// this uses subscription pattern and returns [] when data isn't ready.
// Always check didRecordCommits before using commitData or filteredCommitIndices.
const commitData = useMemo(() => {
if (!didRecordCommits || rootID === null || profilingData === null) {
return [] as Array<CommitDataFrontend>;
}
const dataForRoot = profilingData.dataForRoots.get(rootID);
return dataForRoot
? dataForRoot.commitData
: ([] as Array<CommitDataFrontend>);
}, [didRecordCommits, rootID, profilingData]);
// Commit filtering and navigation
const {
isCommitFilterEnabled,
setIsCommitFilterEnabled,
minCommitDuration,
setMinCommitDuration,
selectedCommitIndex,
selectCommitIndex,
filteredCommitIndices,
selectedFilteredCommitIndex,
selectNextCommitIndex,
selectPrevCommitIndex,
} = useCommitFilteringAndNavigation(commitData);
// Fibers in the selected commit matching `text`, scoped to the current
// commit only (never the whole trace).
const findMatches = useCallback(
(text: string): Array<SearchResult> => {
if (
text === '' ||
rootID === null ||
selectedCommitIndex === null ||
!didRecordCommits
) {
return [];
}
const commitTree = profilerStore.profilingCache.getCommitTree({
commitIndex: selectedCommitIndex,
rootID,
});
return collectSearchMatches(commitTree, text);
},
[rootID, selectedCommitIndex, didRecordCommits, profilerStore],
);
// Keep the controlled input update synchronous (see setSearchText), but
// derive matches from a *deferred* value so the tree walk runs at transition
// priority and never blocks typing. Deriving via a memo also keeps results
// scoped to the current commit for free (findMatches tracks selectedCommitIndex).
const deferredSearchText = useDeferredValue(searchText);
const searchIsPending = searchText !== deferredSearchText;
const searchResults = useMemo<Array<SearchResult>>(
() => findMatches(deferredSearchText),
[findMatches, deferredSearchText],
);
const setSearchText = useCallback((text: string) => {
// Synchronous so the input stays responsive; searchResults recomputes off
// the deferred value at transition priority.
setSearchTextState(text);
setSearchIndex(text === '' ? -1 : 0);
}, []);
const goToNextSearchResult = useCallback(() => {
setSearchIndex(prevIndex => {
const count = searchResults.length;
if (count === 0) {
return -1;
}
return prevIndex < 0 || prevIndex >= count ? 0 : (prevIndex + 1) % count;
});
}, [searchResults.length]);
const goToPreviousSearchResult = useCallback(() => {
setSearchIndex(prevIndex => {
const count = searchResults.length;
if (count === 0) {
return -1;
}
const current = prevIndex < 0 || prevIndex >= count ? count : prevIndex;
return current <= 0 ? count - 1 : current - 1;
});
}, [searchResults.length]);
const goToSearchResult = useCallback(
(index: number) => setSearchIndex(index),
[],
);
// Keep the selected fiber in sync with the current search match *during
// render* rather than in an effect, so results and selection commit together
// (no post-paint frame showing a stale/empty selection). This mirrors the
// existing prevProfilingData pattern above and follows
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
// Note: only the profiler's own selection state is updated here (a render is
// not allowed to dispatch into the Components tree), so search navigation
// intentionally does not sync selection to the Components tab.
const [prevSearchResults, setPrevSearchResults] = useState(searchResults);
const [prevSearchIndex, setPrevSearchIndex] = useState(searchIndex);
if (prevSearchResults !== searchResults || prevSearchIndex !== searchIndex) {
setPrevSearchResults(searchResults);
setPrevSearchIndex(searchIndex);
if (searchText !== '') {
if (searchResults.length === 0) {
selectFiberID(null);
selectFiberName(null);
} else {
const index =
searchIndex < 0 || searchIndex >= searchResults.length
? 0
: searchIndex;
const match = searchResults[index];
selectFiberID(match.id);
selectFiberName(match.name);
}
}
}
const showSearchInput = useCallback(() => setIsSearchInputVisible(true), []);
const hideSearchInput = useCallback(() => {
setIsSearchInputVisible(false);
setSearchTextState('');
setSearchIndex(-1);
}, []);
const startProfiling = useCallback(() => {
logEvent({
event_name: 'profiling-start',
metadata: {current_tab: selectedTabID},
});
// Clear selections when starting a new profiling session
selectCommitIndex(null);
selectFiberID(null);
selectFiberName(null);
// Clear any active search from the previous session.
setIsSearchInputVisible(false);
setSearchTextState('');
setSearchIndex(-1);
store.profilerStore.startProfiling();
}, [store, selectedTabID, selectCommitIndex]);
// Auto-select first commit when profiling data becomes available and no commit is selected.
useEffect(() => {
if (
profilingData !== null &&
selectedCommitIndex === null &&
rootID !== null
) {
const dataForRoot = profilingData.dataForRoots.get(rootID);
if (dataForRoot && dataForRoot.commitData.length > 0) {
selectCommitIndex(0);
}
}
}, [profilingData, rootID, selectCommitIndex]);
const value = useMemo(
() => ({
selectedTabID,
selectTab,
didRecordCommits,
isProcessingData,
isProfiling,
profilingData,
startProfiling,
stopProfiling,
supportsProfiling,
rootID,
setRootID: setRootIDAndClearFiber,
isCommitFilterEnabled,
setIsCommitFilterEnabled,
minCommitDuration,
setMinCommitDuration,
selectedCommitIndex,
selectCommitIndex,
selectNextCommitIndex,
selectPrevCommitIndex,
filteredCommitIndices,
selectedFilteredCommitIndex,
selectedFiberID,
selectedFiberName,
selectFiber,
isSearchInputVisible,
showSearchInput,
hideSearchInput,
searchText,
setSearchText,
searchResults,
searchIndex,
searchIsPending,
goToNextSearchResult,
goToPreviousSearchResult,
goToSearchResult,
}),
[
selectedTabID,
selectTab,
didRecordCommits,
isProcessingData,
isProfiling,
profilingData,
startProfiling,
stopProfiling,
supportsProfiling,
rootID,
setRootIDAndClearFiber,
isCommitFilterEnabled,
setIsCommitFilterEnabled,
minCommitDuration,
setMinCommitDuration,
selectedCommitIndex,
selectCommitIndex,
selectNextCommitIndex,
selectPrevCommitIndex,
filteredCommitIndices,
selectedFilteredCommitIndex,
selectedFiberID,
selectedFiberName,
selectFiber,
isSearchInputVisible,
showSearchInput,
hideSearchInput,
searchText,
setSearchText,
searchResults,
searchIndex,
searchIsPending,
goToNextSearchResult,
goToPreviousSearchResult,
goToSearchResult,
],
);
return (
<ProfilerContext.Provider value={value}>
{children}
</ProfilerContext.Provider>
);
}
export {ProfilerContext, ProfilerContextController};
Loading syntax highlighting…