From 11eddecd916843f31d88630e4d6f8ab7f52b3a8c Mon Sep 17 00:00:00 2001 From: BIKI DAS <72331432+Biki-das@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:30:39 +0530 Subject: [PATCH] [Devtools] Added component search to the Profiler's commit view (#36944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added the search by component name functionality as requested for https://github.com/react/react/issues/32995#issuecomment-4786856255 Adds a component search to the Profiler's commit view, so you can find a specific component within the currently selected commit (Flamegraph & Ranked charts). Previously the only search lived in the Components panel and covered the live tree, not profiling data. Behavior is inspired from Chrome DevTools' in-page find: - Cmd/Ctrl+F opens a collapsible search box floating over the chart (no always-on input). - Shows an N | M match count; ↑/↓ buttons and Enter / Shift+Enter step through matches (with wraparound). - Each match is selected via the existing selectFiber, so it highlights, zooms, updates the sidebar, syncs to the Components tab, and scrolls into view. - Esc or ✕ closes it. - Search is scoped to the selected commit only — never the whole trace. Switching commits re-scopes the count. ## How did you test this change? https://github.com/user-attachments/assets/ab2396e1-f329-4213-b053-9b3d08988c6b --- .../__tests__/__e2e__/profiler.test.js | 100 +++++++++ .../src/devtools/views/ButtonIcon.js | 15 +- .../src/devtools/views/Icon.js | 9 + .../src/devtools/views/Profiler/ChartNode.css | 9 + .../src/devtools/views/Profiler/ChartNode.js | 39 +++- .../views/Profiler/CommitFlamegraph.js | 55 ++++- .../Profiler/CommitFlamegraphListItem.js | 6 + .../devtools/views/Profiler/CommitRanked.js | 53 ++++- .../views/Profiler/CommitRankedListItem.js | 6 + .../src/devtools/views/Profiler/Profiler.css | 27 +++ .../src/devtools/views/Profiler/Profiler.js | 37 +++- .../views/Profiler/ProfilerContext.js | 195 +++++++++++++++++- .../views/Profiler/ProfilerSearchInput.js | 46 +++++ .../src/devtools/views/SearchInput.css | 18 ++ .../src/devtools/views/SearchInput.js | 62 ++++-- 15 files changed, 647 insertions(+), 30 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js diff --git a/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js b/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js index a5bda6fb7053..428333d588f7 100644 --- a/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js +++ b/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js @@ -101,4 +101,104 @@ test.describe('Profiler', () => { '3 / 3' ); }); + + test('should allow searching for a component within the selected commit', async () => { + runOnlyForReactRange('>=16.5'); + + async function waitForSearchResultsCount(expectedText) { + return await page.waitForFunction(expected => { + const {createTestNameSelector, findAllNodes} = + window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const indexInput = findAllNodes(container, [ + createTestNameSelector('ProfilerSearchInput-ResultIndexInput'), + ])[0]; + const resultsCount = findAllNodes(container, [ + createTestNameSelector('ProfilerSearchInput-ResultsCount'), + ])[0]; + if (indexInput === undefined || resultsCount === undefined) { + return false; + } + const totalCount = resultsCount.innerText.replace(/[^0-9]/g, ''); + return `${indexInput.value} | ${totalCount}` === expected; + }, expectedText); + } + + async function focusProfilerSearch() { + await page.evaluate(() => { + const {createTestNameSelector, focusWithin} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + focusWithin(container, [ + createTestNameSelector('ProfilerSearchInput-Input'), + ]); + }); + } + + await devToolsUtils.clickButton(page, 'ProfilerToggleButton'); + await listAppUtils.addItem(page, 'four'); + await listAppUtils.addItem(page, 'five'); + await listAppUtils.addItem(page, 'six'); + await devToolsUtils.clickButton(page, 'ProfilerToggleButton'); + + await page.waitForFunction(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + return ( + findAllNodes(container, [ + createTestNameSelector('SnapshotSelector-Input'), + ]).length === 1 + ); + }); + + await devToolsUtils.clickButton(page, 'ProfilerSearchButton'); + await page.waitForFunction(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + return ( + findAllNodes(container, [ + createTestNameSelector('ProfilerSearchInput-Input'), + ]).length === 1 + ); + }); + + await focusProfilerSearch(); + await page.keyboard.insertText('ListItem'); + await waitForSearchResultsCount('1 | 4'); + + await devToolsUtils.clickButton(page, 'SnapshotSelector-NextButton'); + await waitForSearchResultsCount('1 | 5'); + await devToolsUtils.clickButton(page, 'SnapshotSelector-NextButton'); + await waitForSearchResultsCount('1 | 6'); + await devToolsUtils.clickButton(page, 'SnapshotSelector-PreviousButton'); + await waitForSearchResultsCount('1 | 5'); + await devToolsUtils.clickButton(page, 'SnapshotSelector-PreviousButton'); + await waitForSearchResultsCount('1 | 4'); + + await page.keyboard.press('Enter'); + await waitForSearchResultsCount('2 | 4'); + await page.keyboard.press('Enter'); + await waitForSearchResultsCount('3 | 4'); + await page.keyboard.press('Enter'); + await waitForSearchResultsCount('4 | 4'); + await page.keyboard.press('Enter'); + await waitForSearchResultsCount('1 | 4'); + await page.keyboard.press('Shift+Enter'); + await waitForSearchResultsCount('4 | 4'); + + await page.keyboard.insertText('zzz'); + await waitForSearchResultsCount('0 | 0'); + + await devToolsUtils.clickButton(page, 'ProfilerSearchInput-CloseButton'); + await page.waitForFunction(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + return ( + findAllNodes(container, [ + createTestNameSelector('ProfilerSearchInput-Input'), + ]).length === 0 + ); + }); + }); }); diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js index a3f7b0701ef1..4cc5922b94a7 100644 --- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js +++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js @@ -23,6 +23,7 @@ export type IconType = | 'expanded' | 'export' | 'filter' + | 'find' | 'import' | 'log-data' | 'more' @@ -129,6 +130,10 @@ export default function ButtonIcon({className = '', type}: Props): React.Node { case 'search': pathData = PATH_SEARCH; break; + case 'find': + pathData = PATH_FIND; + viewBox = '0 0 16 16'; + break; case 'settings': pathData = PATH_SETTINGS; break; @@ -211,11 +216,7 @@ export default function ButtonIcon({className = '', type}: Props): React.Node { height="24" viewBox={viewBox}> - {typeof pathData === 'string' ? ( - - ) : ( - pathData - )} + ); } @@ -300,6 +301,10 @@ const PATH_SEARCH = ` M23,13.9l-4.6,3.6l4.6,4.6l-1.1,1.1l-4.7-4.4l-3.3,4.4l-3.2-12.3L23,13.9z `; +const PATH_FIND = + 'M6.5 0.5a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 1.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z' + + 'M11.17 10.03l3.7 3.7a0.8 0.8 0 0 1-1.14 1.14l-3.7-3.7z'; + const PATH_SETTINGS = ` M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 diff --git a/packages/react-devtools-shared/src/devtools/views/Icon.js b/packages/react-devtools-shared/src/devtools/views/Icon.js index d305c6e78d7b..25592f92e754 100644 --- a/packages/react-devtools-shared/src/devtools/views/Icon.js +++ b/packages/react-devtools-shared/src/devtools/views/Icon.js @@ -18,6 +18,7 @@ export type IconType = | 'copy' | 'error' | 'facebook' + | 'find' | 'flame-chart' | 'profiler' | 'ranked-chart' @@ -77,6 +78,10 @@ export default function Icon({ case 'search': pathData = PATH_SEARCH; break; + case 'find': + pathData = PATH_FIND; + viewBox = '0 0 16 16'; + break; case 'settings': pathData = PATH_SETTINGS; break; @@ -161,6 +166,10 @@ const PATH_SEARCH = ` 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z `; +const PATH_FIND = + 'M6.5 0.5a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 1.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z' + + 'M11.17 10.03l3.7 3.7a0.8 0.8 0 0 1-1.14 1.14l-3.7-3.7z'; + const PATH_RANKED_CHART = 'M3 5h18v3H3zM3 10.5h13v3H3zM3 16h8v3H3z'; const PATH_SETTINGS = ` diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.css b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.css index 3117ea19322f..c72afea06219 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.css +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.css @@ -13,6 +13,15 @@ transition: all ease-in-out 250ms; } +.Highlight { + border-radius: 0.125rem; + background-color: var(--color-search-match); +} +.CurrentHighlight { + border-radius: 0.125rem; + background-color: var(--color-search-match-current); +} + .Div { pointer-events: none; white-space: nowrap; diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js index a6acf53fcd0d..081d71fde2f1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js @@ -15,13 +15,16 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti type Props = { color: string, height: number, + isCurrentSearchMatch?: boolean, isDimmed?: boolean, + isSearchMatch?: boolean, label: string, onClick: (event: SyntheticMouseEvent) => mixed, onDoubleClick?: (event: SyntheticMouseEvent) => mixed, onMouseEnter: (event: SyntheticMouseEvent) => mixed, onMouseLeave: (event: SyntheticMouseEvent) => mixed, placeLabelAboveNode?: boolean, + searchRegExp?: RegExp | null, textStyle?: Object, width: number, x: number, @@ -30,20 +33,54 @@ type Props = { const minWidthToDisplay = 35; +// Wrap the matched substring of `label` in a highlight, like the Components +// panel search does (see IndexableDisplayName). +function highlightLabel( + label: string, + searchRegExp: RegExp, + isCurrentSearchMatch: boolean, +): React.Node { + const match = searchRegExp.exec(label); + if (match === null) { + return label; + } + const start = match.index; + const stop = start + match[0].length; + return ( + <> + {start > 0 ? label.slice(0, start) : null} + + {label.slice(start, stop)} + + {stop < label.length ? label.slice(stop) : null} + + ); +} + export default function ChartNode({ color, height, + isCurrentSearchMatch = false, isDimmed = false, + isSearchMatch = false, label, onClick, onMouseEnter, onMouseLeave, onDoubleClick, + searchRegExp, textStyle, width, x, y, }: Props): React.Node { + const content = + isSearchMatch && searchRegExp != null + ? highlightLabel(label, searchRegExp, isCurrentSearchMatch) + : label; return (
- {label} + {content}
)} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.js index 4956516b8788..c5ba02797970 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.js @@ -8,7 +8,15 @@ */ import * as React from 'react'; -import {forwardRef, useCallback, useContext, useMemo, useState} from 'react'; +import { + forwardRef, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import {FixedSizeList} from 'react-window'; import {ProfilerContext} from './ProfilerContext'; @@ -16,6 +24,7 @@ import NoCommitData from './NoCommitData'; import CommitFlamegraphListItem from './CommitFlamegraphListItem'; import HoveredFiberInfo from './HoveredFiberInfo'; import {scale} from './utils'; +import {createRegExp} from '../utils'; import {useHighlightHostInstance} from '../hooks'; import {StoreContext} from '../context'; import {SettingsContext} from '../Settings/SettingsContext'; @@ -29,9 +38,12 @@ import type {CommitTree} from './types'; export type ItemData = { chartData: ChartData, + currentSearchMatchID: number | null, + matchedFiberIDs: Set, onElementMouseEnter: (fiberData: TooltipFiberData) => void, onElementMouseLeave: () => void, scaleX: (value: number, fallbackValue: number) => number, + searchRegExp: RegExp | null, selectedChartNode: ChartNode | null, selectedChartNodeIndex: number, selectFiber: (id: number | null, name: string | null) => void, @@ -100,10 +112,26 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { const [hoveredFiberData, setHoveredFiberData] = useState(null); const {lineHeight} = useContext(SettingsContext); - const {selectFiber, selectedFiberID} = useContext(ProfilerContext); + const {selectFiber, selectedFiberID, searchText, searchResults, searchIndex} = + useContext(ProfilerContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); + // Search highlighting: the regexp to highlight, the set of matching fibers, + // and the id of the current match (highlighted more prominently). + const searchRegExp = useMemo( + () => (searchText === '' ? null : createRegExp(searchText)), + [searchText], + ); + const matchedFiberIDs = useMemo( + () => new Set(searchResults.map(result => result.id)), + [searchResults], + ); + const currentSearchMatchID = + searchIndex >= 0 && searchIndex < searchResults.length + ? searchResults[searchIndex].id + : null; + const selectedChartNodeIndex = useMemo(() => { if (selectedFiberID === null) { return 0; @@ -141,6 +169,8 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { const itemData = useMemo( () => ({ chartData, + currentSearchMatchID, + matchedFiberIDs, onElementMouseEnter: handleElementMouseEnter, onElementMouseLeave: handleElementMouseLeave, scaleX: scale( @@ -151,6 +181,7 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { 0, width, ), + searchRegExp, selectedChartNode, selectedChartNodeIndex, selectFiber, @@ -158,8 +189,11 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { }), [ chartData, + currentSearchMatchID, + matchedFiberIDs, handleElementMouseEnter, handleElementMouseLeave, + searchRegExp, selectedChartNode, selectedChartNodeIndex, selectFiber, @@ -176,6 +210,22 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) { [hoveredFiberData], ); + // Scroll the selected fiber's row into view when the selection changes (e.g. + // when navigating between search results). Selection is driven externally + // (search nav in ProfilerContext, or a node click) and selectedChartNodeIndex + // is derived here — no local event handler sets it — so we sync the imperative + // scroll in a layout effect, which runs before paint to avoid a frame where + // the scroll position lags the selection. + const listRef = useRef(null); + const itemIsSelected = selectedFiberID !== null; + useLayoutEffect(() => { + // selectedChartNodeIndex falls back to 0 when nothing is selected, so only + // scroll when a fiber is actually selected. + if (itemIsSelected && listRef.current !== null) { + listRef.current.scrollToItem(selectedChartNodeIndex, 'smart'); + } + }, [itemIsSelected, selectedChartNodeIndex]); + return ( {CommitFlamegraphListItem} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js index ac846b7be80d..a531bb83db02 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js @@ -29,9 +29,12 @@ type Props = { function CommitFlamegraphListItem({data, index, style}: Props): React.Node { const { chartData, + currentSearchMatchID, + matchedFiberIDs, onElementMouseEnter, onElementMouseLeave, scaleX, + searchRegExp, selectedChartNode, selectedChartNodeIndex, selectFiber, @@ -115,12 +118,15 @@ function CommitFlamegraphListItem({data, index, style}: Props): React.Node { handleClick(event, id, name)} onMouseEnter={() => handleMouseEnter(chartNode)} onMouseLeave={handleMouseLeave} + searchRegExp={searchRegExp} textStyle={{color: textColor}} width={nodeWidth} x={nodeOffset - selectedNodeOffset} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js index 57ed9983dd77..aa642e3c001a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js @@ -8,7 +8,14 @@ */ import * as React from 'react'; -import {useCallback, useContext, useMemo, useState} from 'react'; +import { + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import {FixedSizeList} from 'react-window'; import {ProfilerContext} from './ProfilerContext'; @@ -16,6 +23,7 @@ import NoCommitData from './NoCommitData'; import CommitRankedListItem from './CommitRankedListItem'; import HoveredFiberInfo from './HoveredFiberInfo'; import {scale} from './utils'; +import {createRegExp} from '../utils'; import {StoreContext} from '../context'; import {SettingsContext} from '../Settings/SettingsContext'; import {useHighlightHostInstance} from '../hooks'; @@ -29,9 +37,12 @@ import type {CommitTree} from './types'; export type ItemData = { chartData: ChartData, + currentSearchMatchID: number | null, + matchedFiberIDs: Set, onElementMouseEnter: (fiberData: TooltipFiberData) => void, onElementMouseLeave: () => void, scaleX: (value: number, fallbackValue: number) => number, + searchRegExp: RegExp | null, selectedFiberID: number | null, selectedFiberIndex: number, selectFiber: (id: number | null, name: string | null) => void, @@ -98,7 +109,8 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { const [hoveredFiberData, setHoveredFiberData] = useState(null); const {lineHeight} = useContext(SettingsContext); - const {selectedFiberID, selectFiber} = useContext(ProfilerContext); + const {selectedFiberID, selectFiber, searchText, searchResults, searchIndex} = + useContext(ProfilerContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); @@ -107,6 +119,20 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { [chartData, selectedFiberID], ); + // Search highlighting (see CommitFlamegraph for details). + const searchRegExp = useMemo( + () => (searchText === '' ? null : createRegExp(searchText)), + [searchText], + ); + const matchedFiberIDs = useMemo( + () => new Set(searchResults.map(result => result.id)), + [searchResults], + ); + const currentSearchMatchID = + searchIndex >= 0 && searchIndex < searchResults.length + ? searchResults[searchIndex].id + : null; + const handleElementMouseEnter = useCallback( ({id, name}: $FlowFixMe) => { highlightHostInstance(id); // Highlight last hovered element. @@ -123,9 +149,12 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { const itemData = useMemo( () => ({ chartData, + currentSearchMatchID, + matchedFiberIDs, onElementMouseEnter: handleElementMouseEnter, onElementMouseLeave: handleElementMouseLeave, scaleX: scale(0, chartData.nodes[selectedFiberIndex].value, 0, width), + searchRegExp, selectedFiberID, selectedFiberIndex, selectFiber, @@ -133,8 +162,11 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { }), [ chartData, + currentSearchMatchID, + matchedFiberIDs, handleElementMouseEnter, handleElementMouseLeave, + searchRegExp, selectedFiberID, selectedFiberIndex, selectFiber, @@ -151,6 +183,22 @@ function CommitRanked({chartData, commitTree, height, width}: Props) { [hoveredFiberData], ); + // Scroll the selected fiber's row into view when the selection changes (e.g. + // when navigating between search results). Selection is driven externally + // (search nav in ProfilerContext, or a node click) and selectedFiberIndex is + // derived here — no local event handler sets it — so we sync the imperative + // scroll in a layout effect, which runs before paint to avoid a frame where + // the scroll position lags the selection. + const listRef = useRef(null); + const itemIsSelected = selectedFiberID !== null; + useLayoutEffect(() => { + // selectedFiberIndex falls back to 0 when nothing is selected, so only + // scroll when a fiber is actually selected. + if (itemIsSelected && listRef.current !== null) { + listRef.current.scrollToItem(selectedFiberIndex, 'smart'); + } + }, [itemIsSelected, selectedFiberIndex]); + return ( {CommitRankedListItem} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js index 99da9e9f0885..bf3344dfb94e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js @@ -26,9 +26,12 @@ type Props = { function CommitRankedListItem({data, index, style}: Props) { const { chartData, + currentSearchMatchID, + matchedFiberIDs, onElementMouseEnter, onElementMouseLeave, scaleX, + searchRegExp, selectedFiberIndex, selectFiber, width, @@ -66,12 +69,15 @@ function CommitRankedListItem({data, index, style}: Props) { { @@ -65,6 +71,16 @@ function Profiler(_: {}) { } event.preventDefault(); event.stopPropagation(); + } else if (didRecordCommits && correctModifier && event.key === 'f') { + // Cmd+F (Mac) or Ctrl+F (Windows/Linux) to search components in the commit + showSearchInput(); + event.preventDefault(); + event.stopPropagation(); + } else if (isSearchInputVisible && event.key === 'Escape') { + // Escape closes the search input. + hideSearchInput(); + event.preventDefault(); + event.stopPropagation(); } else if (didRecordCommits && selectedCommitIndex !== null) { // Cmd+Left/Right (Mac) or Ctrl+Left/Right (Windows/Linux) to navigate commits if ( @@ -88,9 +104,11 @@ function Profiler(_: {}) { return; } const ownerWindow = div.ownerDocument.defaultView; - ownerWindow.addEventListener('keydown', handleKeyDown); + // Capture phase: Cmd/Ctrl+F is a reserved browser shortcut (Find), so we + // must intercept it before the browser to open our own search instead. + ownerWindow.addEventListener('keydown', handleKeyDown, true); return () => { - ownerWindow.removeEventListener('keydown', handleKeyDown); + ownerWindow.removeEventListener('keydown', handleKeyDown, true); }; }, []); @@ -163,11 +181,26 @@ function Profiler(_: {}) { {didRecordCommits && (
+ )}
+ {didRecordCommits && isSearchInputVisible && ( +
+ +
+ )} {view}
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 7539573fc6c0..1867b9d05d95 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js @@ -14,6 +14,7 @@ import { createContext, useCallback, useContext, + useDeferredValue, useMemo, useState, useEffect, @@ -24,13 +25,53 @@ import { 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, ProfilingDataFrontend} from './types'; +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 { + const regExp = createRegExp(text); + const matches: Array = []; + 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, @@ -80,6 +121,21 @@ export type Context = { 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, + searchIndex: number, + searchIsPending: boolean, + goToNextSearchResult(): void, + goToPreviousSearchResult(): void, + goToSearchResult: (index: number) => void, }; const ProfilerContext: ReactContext = createContext( @@ -144,6 +200,12 @@ function ProfilerContextController({children}: Props): React.Node { const [selectedFiberID, selectFiberID] = useState(null); const [selectedFiberName, selectFiberName] = useState(null); + // Component search (scoped to the currently selected commit). + const [isSearchInputVisible, setIsSearchInputVisible] = + useState(false); + const [searchText, setSearchTextState] = useState(''); + const [searchIndex, setSearchIndex] = useState(-1); + const selectFiber = useCallback( (id: number | null, name: string | null) => { selectFiberID(id); @@ -255,6 +317,108 @@ function ProfilerContextController({children}: Props): React.Node { 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 => { + 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>( + () => 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', @@ -266,6 +430,11 @@ function ProfilerContextController({children}: Props): React.Node { selectFiberID(null); selectFiberName(null); + // Clear any active search from the previous session. + setIsSearchInputVisible(false); + setSearchTextState(''); + setSearchIndex(-1); + store.profilerStore.startProfiling(); }, [store, selectedTabID, selectCommitIndex]); @@ -314,6 +483,18 @@ function ProfilerContextController({children}: Props): React.Node { selectedFiberID, selectedFiberName, selectFiber, + + isSearchInputVisible, + showSearchInput, + hideSearchInput, + searchText, + setSearchText, + searchResults, + searchIndex, + searchIsPending, + goToNextSearchResult, + goToPreviousSearchResult, + goToSearchResult, }), [ selectedTabID, @@ -345,6 +526,18 @@ function ProfilerContextController({children}: Props): React.Node { selectedFiberID, selectedFiberName, selectFiber, + + isSearchInputVisible, + showSearchInput, + hideSearchInput, + searchText, + setSearchText, + searchResults, + searchIndex, + searchIsPending, + goToNextSearchResult, + goToPreviousSearchResult, + goToSearchResult, ], ); 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 000000000000..93d024934c40 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js @@ -0,0 +1,46 @@ +/** + * 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 {useContext} from 'react'; + +import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput'; +import {ProfilerContext} from './ProfilerContext'; + +export default function ProfilerSearchInput(): React.Node { + const { + searchText, + setSearchText, + searchResults, + searchIndex, + searchIsPending, + goToNextSearchResult, + goToPreviousSearchResult, + goToSearchResult, + hideSearchInput, + } = useContext(ProfilerContext); + + return ( + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SearchInput.css b/packages/react-devtools-shared/src/devtools/views/SearchInput.css index a61855eea09a..cec3d9cb5b91 100644 --- a/packages/react-devtools-shared/src/devtools/views/SearchInput.css +++ b/packages/react-devtools-shared/src/devtools/views/SearchInput.css @@ -28,6 +28,24 @@ white-space: pre; } +/* Shown while the (deferred) search is still computing matches. */ +.Spinner { + flex: 0 0 auto; + width: 0.75rem; + height: 0.75rem; + margin: 0 0.25rem; + border: 2px solid var(--color-border); + border-top-color: var(--color-dim); + border-radius: 50%; + animation: SearchInput-spin 0.6s linear infinite; +} + +@keyframes SearchInput-spin { + to { + transform: rotate(360deg); + } +} + .IndexInput { color: var(--color-text); font-size: var(--font-size-sans-normal); diff --git a/packages/react-devtools-shared/src/devtools/views/SearchInput.js b/packages/react-devtools-shared/src/devtools/views/SearchInput.js index 745d9cdc51bf..892db2d8b9d3 100644 --- a/packages/react-devtools-shared/src/devtools/views/SearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/SearchInput.js @@ -17,13 +17,18 @@ import {useEffect, useRef, useState} from 'react'; import Button from './Button'; import ButtonIcon from './ButtonIcon'; import Icon from './Icon'; +import type {IconType} from './Icon'; import AutoSizeInput from './Components/NativeStyleEditor/AutoSizeInput'; import styles from './SearchInput.css'; type Props = { + autoFocus?: boolean, goToNextResult: () => void, goToPreviousResult: () => void, + iconType?: IconType, + isPending?: boolean, + onClose?: () => void, goToResult: (index: number) => void, placeholder: string, search: (text: string) => void, @@ -34,8 +39,12 @@ type Props = { }; export default function SearchInput({ + autoFocus, goToNextResult, goToPreviousResult, + iconType = 'search', + isPending, + onClose, goToResult, placeholder, search, @@ -96,26 +105,28 @@ export default function SearchInput({ // Auto-focus search input useEffect(() => { - if (inputRef.current === null) { + const input = inputRef.current; + if (input === null) { return () => {}; } + if (autoFocus) { + input.focus(); + } + const handleKeyDown = (event: KeyboardEvent) => { const {key, metaKey} = event; if (key === 'f' && metaKey) { - const inputElement = inputRef.current; - if (inputElement !== null) { - inputElement.focus(); - event.preventDefault(); - event.stopPropagation(); - } + input.focus(); + event.preventDefault(); + event.stopPropagation(); } }; // It's important to listen to the ownerDocument to support the browser extension. // Here we use portals to render individual tabs (e.g. Profiler), // and the root document might belong to a different window. - const ownerDocumentElement = inputRef.current.ownerDocument.documentElement; + const ownerDocumentElement = input.ownerDocument.documentElement; if (ownerDocumentElement === null) { return; } @@ -123,11 +134,11 @@ export default function SearchInput({ return () => ownerDocumentElement.removeEventListener('keydown', handleKeyDown); - }, []); + }, [autoFocus]); return (
- + + {isPending === true && ( + + )} {!!searchText && ( - + {onClose == null && ( + + )} )} + {onClose != null && ( + + )}
); }