diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js index 6254be06d83..a3bc84da595 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js @@ -28,6 +28,7 @@ import RecordingInProgress from './RecordingInProgress'; import ProcessingData from './ProcessingData'; import ProfilingNotSupported from './ProfilingNotSupported'; import SidebarSelectedFiberInfo from './SidebarSelectedFiberInfo'; +import ProfilerSearchInput from './ProfilerSearchInput'; import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; @@ -188,6 +189,9 @@ function Profiler(_: {}) { className={styles.TimelineSearchInputContainer} /> )} + {isLegacyProfilerSelected && didRecordCommits && selectedCommitIndex !== null && ( + + )} {isLegacyProfilerSelected && didRecordCommits && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js index d593558dbdd..22afe39766f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js @@ -26,8 +26,10 @@ import { import {StoreContext} from '../context'; import {logEvent} from 'react-devtools-shared/src/Logger'; import {useCommitFilteringAndNavigation} from './useCommitFilteringAndNavigation'; +import {createRegExp} from '../utils'; import type {CommitDataFrontend, ProfilingDataFrontend} from './types'; +import type {CommitTree} from './types'; export type TabID = 'flame-chart' | 'ranked-chart' | 'timeline'; @@ -80,6 +82,14 @@ export type Context = { selectedFiberID: number | null, selectedFiberName: string | null, selectFiber: (id: number | null, name: string | null) => void, + + // Search functionality for components in the selected commit + searchText: string, + searchResults: Array, + searchIndex: number | null, + setSearchText: (text: string) => void, + goToNextSearchResult: () => void, + goToPreviousSearchResult: () => void, }; const ProfilerContext: ReactContext = createContext( @@ -143,6 +153,9 @@ function ProfilerContextController({children}: Props): React.Node { const [rootID, setRootID] = useState(null); const [selectedFiberID, selectFiberID] = useState(null); const [selectedFiberName, selectFiberName] = useState(null); + const [searchText, setSearchTextState] = useState(''); + const [searchResults, setSearchResults] = useState>([]); + const [searchIndex, setSearchIndex] = useState(null); const selectFiber = useCallback( (id: number | null, name: string | null) => { @@ -278,6 +291,149 @@ function ProfilerContextController({children}: Props): React.Node { } }, [profilingData, rootID, selectCommitIndex]); + // Search functionality: filter components in the selected commit by name + const performSearch = useCallback( + (text: string, commitTree: CommitTree | null): Array => { + if (!text || !commitTree) { + return []; + } + + const regExp = createRegExp(text); + const results: Array = []; + + // Recursively search through the commit tree + const searchTree = (nodeID: number) => { + const node = commitTree.nodes.get(nodeID); + if (!node) { + return; + } + + const displayName = node.displayName || 'Anonymous'; + if (regExp.test(displayName)) { + results.push(nodeID); + } + + // Search children + node.children.forEach(childID => { + searchTree(childID); + }); + }; + + if (commitTree.rootID !== null) { + searchTree(commitTree.rootID); + } + + return results; + }, + [], + ); + + // Update search results when search text, commit, or root changes + useEffect(() => { + if ( + searchText === '' || + selectedCommitIndex === null || + rootID === null || + !profilingData + ) { + setSearchResults([]); + setSearchIndex(null); + return; + } + + const commitTree = profilerStore.profilingCache.getCommitTree({ + commitIndex: selectedCommitIndex, + rootID, + }); + + const results = performSearch(searchText, commitTree); + setSearchResults(results); + + // Set initial search index + if (results.length > 0) { + // If current selected fiber matches, find its index + if (selectedFiberID !== null && results.includes(selectedFiberID)) { + setSearchIndex(results.indexOf(selectedFiberID)); + } else { + setSearchIndex(0); + // Auto-select first result + const firstResultID = results[0]; + const node = commitTree.nodes.get(firstResultID); + if (node) { + selectFiber(firstResultID, node.displayName || 'Anonymous'); + } + } + } else { + setSearchIndex(null); + } + }, [ + searchText, + selectedCommitIndex, + rootID, + profilingData, + profilerStore, + performSearch, + selectedFiberID, + selectFiber, + ]); + + // Clear search when commit changes + useEffect(() => { + setSearchTextState(''); + setSearchResults([]); + setSearchIndex(null); + }, [selectedCommitIndex]); + + const setSearchText = useCallback((text: string) => { + setSearchTextState(text); + }, []); + + const goToNextSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const currentIndex = searchIndex !== null ? searchIndex : -1; + const nextIndex = + currentIndex + 1 < searchResults.length ? currentIndex + 1 : 0; + setSearchIndex(nextIndex); + + const nextResultID = searchResults[nextIndex]; + if (nextResultID !== null && selectedCommitIndex !== null && rootID !== null) { + const commitTree = profilerStore.profilingCache.getCommitTree({ + commitIndex: selectedCommitIndex, + rootID, + }); + const node = commitTree.nodes.get(nextResultID); + if (node) { + selectFiber(nextResultID, node.displayName || 'Anonymous'); + } + } + }, [searchResults, searchIndex, selectedCommitIndex, rootID, profilerStore, selectFiber]); + + const goToPreviousSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const currentIndex = searchIndex !== null ? searchIndex : searchResults.length; + const prevIndex = + currentIndex > 0 ? currentIndex - 1 : searchResults.length - 1; + setSearchIndex(prevIndex); + + const prevResultID = searchResults[prevIndex]; + if (prevResultID !== null && selectedCommitIndex !== null && rootID !== null) { + const commitTree = profilerStore.profilingCache.getCommitTree({ + commitIndex: selectedCommitIndex, + rootID, + }); + const node = commitTree.nodes.get(prevResultID); + if (node) { + selectFiber(prevResultID, node.displayName || 'Anonymous'); + } + } + }, [searchResults, searchIndex, selectedCommitIndex, rootID, profilerStore, selectFiber]); + const value = useMemo( () => ({ selectedTabID, @@ -309,6 +465,13 @@ function ProfilerContextController({children}: Props): React.Node { selectedFiberID, selectedFiberName, selectFiber, + + searchText, + searchResults, + searchIndex, + setSearchText, + goToNextSearchResult, + goToPreviousSearchResult, }), [ selectedTabID, @@ -340,6 +503,13 @@ function ProfilerContextController({children}: Props): React.Node { selectedFiberID, selectedFiberName, selectFiber, + + searchText, + searchResults, + searchIndex, + setSearchText, + goToNextSearchResult, + goToPreviousSearchResult, ], ); diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js new file mode 100644 index 00000000000..278016b5af3 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js @@ -0,0 +1,63 @@ +/** + * 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 * as React from 'react'; +import {useState, useContext, useCallback, useEffect} from 'react'; + +import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput'; +import {ProfilerContext} from './ProfilerContext'; + +export default function ProfilerSearchInput(): React.Node { + const [localSearchQuery, setLocalSearchQuery] = useState(''); + const { + searchIndex, + searchResults, + searchText, + setSearchText, + goToNextSearchResult, + goToPreviousSearchResult, + } = useContext(ProfilerContext); + + // Sync local state with context when search is cleared externally (e.g., commit change) + useEffect(() => { + if (searchText === '' && localSearchQuery !== '') { + setLocalSearchQuery(''); + } + }, [searchText, localSearchQuery]); + + const search = useCallback( + (text: string) => { + setLocalSearchQuery(text); + setSearchText(text); + }, + [setLocalSearchQuery, setSearchText], + ); + const goToNextResult = useCallback( + () => goToNextSearchResult(), + [goToNextSearchResult], + ); + const goToPreviousResult = useCallback( + () => goToPreviousSearchResult(), + [goToPreviousSearchResult], + ); + + return ( + + ); +} +