@samitouri / QOS-React-2 / commits / 11eddecd91

[Devtools] Added component search to the Profiler's commit view (#36944)

## 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

BIKI DAS committed Aug 5, 2026 at 15:30 UTC 11eddecd916843f31d88630e4d6f8ab7f52b3a8c
15 files changed +647 -30
packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js
+100
@@ -101,4 +101,104 @@ test.describe('Profiler', () => {
101 '3 / 3'
102 );
103 });
104 +
105 + test('should allow searching for a component within the selected commit', async () => {
106 + runOnlyForReactRange('>=16.5');
107 +
108 + async function waitForSearchResultsCount(expectedText) {
109 + return await page.waitForFunction(expected => {
110 + const {createTestNameSelector, findAllNodes} =
111 + window.REACT_DOM_DEVTOOLS;
112 + const container = document.getElementById('devtools');
113 +
114 + const indexInput = findAllNodes(container, [
115 + createTestNameSelector('ProfilerSearchInput-ResultIndexInput'),
116 + ])[0];
117 + const resultsCount = findAllNodes(container, [
118 + createTestNameSelector('ProfilerSearchInput-ResultsCount'),
119 + ])[0];
120 + if (indexInput === undefined || resultsCount === undefined) {
121 + return false;
122 + }
123 + const totalCount = resultsCount.innerText.replace(/[^0-9]/g, '');
124 + return `${indexInput.value} | ${totalCount}` === expected;
125 + }, expectedText);
126 + }
127 +
128 + async function focusProfilerSearch() {
129 + await page.evaluate(() => {
130 + const {createTestNameSelector, focusWithin} = window.REACT_DOM_DEVTOOLS;
131 + const container = document.getElementById('devtools');
132 +
133 + focusWithin(container, [
134 + createTestNameSelector('ProfilerSearchInput-Input'),
135 + ]);
136 + });
137 + }
138 +
139 + await devToolsUtils.clickButton(page, 'ProfilerToggleButton');
140 + await listAppUtils.addItem(page, 'four');
141 + await listAppUtils.addItem(page, 'five');
142 + await listAppUtils.addItem(page, 'six');
143 + await devToolsUtils.clickButton(page, 'ProfilerToggleButton');
144 +
145 + await page.waitForFunction(() => {
146 + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS;
147 + const container = document.getElementById('devtools');
148 + return (
149 + findAllNodes(container, [
150 + createTestNameSelector('SnapshotSelector-Input'),
151 + ]).length === 1
152 + );
153 + });
154 +
155 + await devToolsUtils.clickButton(page, 'ProfilerSearchButton');
156 + await page.waitForFunction(() => {
157 + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS;
158 + const container = document.getElementById('devtools');
159 + return (
160 + findAllNodes(container, [
161 + createTestNameSelector('ProfilerSearchInput-Input'),
162 + ]).length === 1
163 + );
164 + });
165 +
166 + await focusProfilerSearch();
167 + await page.keyboard.insertText('ListItem');
168 + await waitForSearchResultsCount('1 | 4');
169 +
170 + await devToolsUtils.clickButton(page, 'SnapshotSelector-NextButton');
171 + await waitForSearchResultsCount('1 | 5');
172 + await devToolsUtils.clickButton(page, 'SnapshotSelector-NextButton');
173 + await waitForSearchResultsCount('1 | 6');
174 + await devToolsUtils.clickButton(page, 'SnapshotSelector-PreviousButton');
175 + await waitForSearchResultsCount('1 | 5');
176 + await devToolsUtils.clickButton(page, 'SnapshotSelector-PreviousButton');
177 + await waitForSearchResultsCount('1 | 4');
178 +
179 + await page.keyboard.press('Enter');
180 + await waitForSearchResultsCount('2 | 4');
181 + await page.keyboard.press('Enter');
182 + await waitForSearchResultsCount('3 | 4');
183 + await page.keyboard.press('Enter');
184 + await waitForSearchResultsCount('4 | 4');
185 + await page.keyboard.press('Enter');
186 + await waitForSearchResultsCount('1 | 4');
187 + await page.keyboard.press('Shift+Enter');
188 + await waitForSearchResultsCount('4 | 4');
189 +
190 + await page.keyboard.insertText('zzz');
191 + await waitForSearchResultsCount('0 | 0');
192 +
193 + await devToolsUtils.clickButton(page, 'ProfilerSearchInput-CloseButton');
194 + await page.waitForFunction(() => {
195 + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS;
196 + const container = document.getElementById('devtools');
197 + return (
198 + findAllNodes(container, [
199 + createTestNameSelector('ProfilerSearchInput-Input'),
200 + ]).length === 0
201 + );
202 + });
203 + });
204 });
packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+10 -5
@@ -23,6 +23,7 @@ export type IconType =
23 | 'expanded'
24 | 'export'
25 | 'filter'
26 + | 'find'
27 | 'import'
28 | 'log-data'
29 | 'more'
@@ -129,6 +130,10 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
130 case 'search':
131 pathData = PATH_SEARCH;
132 break;
133 + case 'find':
134 + pathData = PATH_FIND;
135 + viewBox = '0 0 16 16';
136 + break;
137 case 'settings':
138 pathData = PATH_SETTINGS;
139 break;
@@ -211,11 +216,7 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
216 height="24"
217 viewBox={viewBox}>
218 <path d="M0 0h24v24H0z" fill="none" />
214 - {typeof pathData === 'string' ? (
215 - <path fill="currentColor" d={pathData} />
216 - ) : (
217 - pathData
218 - )}
219 + <path fill="currentColor" d={pathData} />
220 </svg>
221 );
222 }
@@ -300,6 +301,10 @@ const PATH_SEARCH = `
301 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
302 `;
303
304 +const PATH_FIND =
305 + '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' +
306 + 'M11.17 10.03l3.7 3.7a0.8 0.8 0 0 1-1.14 1.14l-3.7-3.7z';
307 +
308 const PATH_SETTINGS = `
309 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
310 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
packages/react-devtools-shared/src/devtools/views/Icon.js
+9
@@ -18,6 +18,7 @@ export type IconType =
18 | 'copy'
19 | 'error'
20 | 'facebook'
21 + | 'find'
22 | 'flame-chart'
23 | 'profiler'
24 | 'ranked-chart'
@@ -77,6 +78,10 @@ export default function Icon({
78 case 'search':
79 pathData = PATH_SEARCH;
80 break;
81 + case 'find':
82 + pathData = PATH_FIND;
83 + viewBox = '0 0 16 16';
84 + break;
85 case 'settings':
86 pathData = PATH_SETTINGS;
87 break;
@@ -161,6 +166,10 @@ const PATH_SEARCH = `
166 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z
167 `;
168
169 +const PATH_FIND =
170 + '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' +
171 + 'M11.17 10.03l3.7 3.7a0.8 0.8 0 0 1-1.14 1.14l-3.7-3.7z';
172 +
173 const PATH_RANKED_CHART = 'M3 5h18v3H3zM3 10.5h13v3H3zM3 16h8v3H3z';
174
175 const PATH_SETTINGS = `
packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.css
+9
@@ -13,6 +13,15 @@
13 transition: all ease-in-out 250ms;
14 }
15
16 +.Highlight {
17 + border-radius: 0.125rem;
18 + background-color: var(--color-search-match);
19 +}
20 +.CurrentHighlight {
21 + border-radius: 0.125rem;
22 + background-color: var(--color-search-match-current);
23 +}
24 +
25 .Div {
26 pointer-events: none;
27 white-space: nowrap;
packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.js
+38 -1
@@ -15,13 +15,16 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
15 type Props = {
16 color: string,
17 height: number,
18 + isCurrentSearchMatch?: boolean,
19 isDimmed?: boolean,
20 + isSearchMatch?: boolean,
21 label: string,
22 onClick: (event: SyntheticMouseEvent) => mixed,
23 onDoubleClick?: (event: SyntheticMouseEvent) => mixed,
24 onMouseEnter: (event: SyntheticMouseEvent) => mixed,
25 onMouseLeave: (event: SyntheticMouseEvent) => mixed,
26 placeLabelAboveNode?: boolean,
27 + searchRegExp?: RegExp | null,
28 textStyle?: Object,
29 width: number,
30 x: number,
@@ -30,20 +33,54 @@ type Props = {
33
34 const minWidthToDisplay = 35;
35
36 +// Wrap the matched substring of `label` in a highlight, like the Components
37 +// panel search does (see IndexableDisplayName).
38 +function highlightLabel(
39 + label: string,
40 + searchRegExp: RegExp,
41 + isCurrentSearchMatch: boolean,
42 +): React.Node {
43 + const match = searchRegExp.exec(label);
44 + if (match === null) {
45 + return label;
46 + }
47 + const start = match.index;
48 + const stop = start + match[0].length;
49 + return (
50 + <>
51 + {start > 0 ? label.slice(0, start) : null}
52 + <mark
53 + className={
54 + isCurrentSearchMatch ? styles.CurrentHighlight : styles.Highlight
55 + }>
56 + {label.slice(start, stop)}
57 + </mark>
58 + {stop < label.length ? label.slice(stop) : null}
59 + </>
60 + );
61 +}
62 +
63 export default function ChartNode({
64 color,
65 height,
66 + isCurrentSearchMatch = false,
67 isDimmed = false,
68 + isSearchMatch = false,
69 label,
70 onClick,
71 onMouseEnter,
72 onMouseLeave,
73 onDoubleClick,
74 + searchRegExp,
75 textStyle,
76 width,
77 x,
78 y,
79 }: Props): React.Node {
80 + const content =
81 + isSearchMatch && searchRegExp != null
82 + ? highlightLabel(label, searchRegExp, isCurrentSearchMatch)
83 + : label;
84 return (
85 <g className={styles.Group} transform={`translate(${x},${y})`}>
86 <rect
@@ -71,7 +108,7 @@ export default function ChartNode({
108 }}
109 y={0}>
110 <div className={styles.Div} style={textStyle}>
74 - {label}
111 + {content}
112 </div>
113 </foreignObject>
114 )}
packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.js
+53 -2
@@ -8,7 +8,15 @@
8 */
9
10 import * as React from 'react';
11 -import {forwardRef, useCallback, useContext, useMemo, useState} from 'react';
11 +import {
12 + forwardRef,
13 + useCallback,
14 + useContext,
15 + useLayoutEffect,
16 + useMemo,
17 + useRef,
18 + useState,
19 +} from 'react';
20 import AutoSizer from 'react-virtualized-auto-sizer';
21 import {FixedSizeList} from 'react-window';
22 import {ProfilerContext} from './ProfilerContext';
@@ -16,6 +24,7 @@ import NoCommitData from './NoCommitData';
24 import CommitFlamegraphListItem from './CommitFlamegraphListItem';
25 import HoveredFiberInfo from './HoveredFiberInfo';
26 import {scale} from './utils';
27 +import {createRegExp} from '../utils';
28 import {useHighlightHostInstance} from '../hooks';
29 import {StoreContext} from '../context';
30 import {SettingsContext} from '../Settings/SettingsContext';
@@ -29,9 +38,12 @@ import type {CommitTree} from './types';
38
39 export type ItemData = {
40 chartData: ChartData,
41 + currentSearchMatchID: number | null,
42 + matchedFiberIDs: Set<number>,
43 onElementMouseEnter: (fiberData: TooltipFiberData) => void,
44 onElementMouseLeave: () => void,
45 scaleX: (value: number, fallbackValue: number) => number,
46 + searchRegExp: RegExp | null,
47 selectedChartNode: ChartNode | null,
48 selectedChartNodeIndex: number,
49 selectFiber: (id: number | null, name: string | null) => void,
@@ -100,10 +112,26 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
112 const [hoveredFiberData, setHoveredFiberData] =
113 useState<TooltipFiberData | null>(null);
114 const {lineHeight} = useContext(SettingsContext);
103 - const {selectFiber, selectedFiberID} = useContext(ProfilerContext);
115 + const {selectFiber, selectedFiberID, searchText, searchResults, searchIndex} =
116 + useContext(ProfilerContext);
117 const {highlightHostInstance, clearHighlightHostInstance} =
118 useHighlightHostInstance();
119
120 + // Search highlighting: the regexp to highlight, the set of matching fibers,
121 + // and the id of the current match (highlighted more prominently).
122 + const searchRegExp = useMemo(
123 + () => (searchText === '' ? null : createRegExp(searchText)),
124 + [searchText],
125 + );
126 + const matchedFiberIDs = useMemo(
127 + () => new Set(searchResults.map(result => result.id)),
128 + [searchResults],
129 + );
130 + const currentSearchMatchID =
131 + searchIndex >= 0 && searchIndex < searchResults.length
132 + ? searchResults[searchIndex].id
133 + : null;
134 +
135 const selectedChartNodeIndex = useMemo<number>(() => {
136 if (selectedFiberID === null) {
137 return 0;
@@ -141,6 +169,8 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
169 const itemData = useMemo<ItemData>(
170 () => ({
171 chartData,
172 + currentSearchMatchID,
173 + matchedFiberIDs,
174 onElementMouseEnter: handleElementMouseEnter,
175 onElementMouseLeave: handleElementMouseLeave,
176 scaleX: scale(
@@ -151,6 +181,7 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
181 0,
182 width,
183 ),
184 + searchRegExp,
185 selectedChartNode,
186 selectedChartNodeIndex,
187 selectFiber,
@@ -158,8 +189,11 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
189 }),
190 [
191 chartData,
192 + currentSearchMatchID,
193 + matchedFiberIDs,
194 handleElementMouseEnter,
195 handleElementMouseLeave,
196 + searchRegExp,
197 selectedChartNode,
198 selectedChartNodeIndex,
199 selectFiber,
@@ -176,6 +210,22 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
210 [hoveredFiberData],
211 );
212
213 + // Scroll the selected fiber's row into view when the selection changes (e.g.
214 + // when navigating between search results). Selection is driven externally
215 + // (search nav in ProfilerContext, or a node click) and selectedChartNodeIndex
216 + // is derived here — no local event handler sets it — so we sync the imperative
217 + // scroll in a layout effect, which runs before paint to avoid a frame where
218 + // the scroll position lags the selection.
219 + const listRef = useRef<FixedSizeList | null>(null);
220 + const itemIsSelected = selectedFiberID !== null;
221 + useLayoutEffect(() => {
222 + // selectedChartNodeIndex falls back to 0 when nothing is selected, so only
223 + // scroll when a fiber is actually selected.
224 + if (itemIsSelected && listRef.current !== null) {
225 + listRef.current.scrollToItem(selectedChartNodeIndex, 'smart');
226 + }
227 + }, [itemIsSelected, selectedChartNodeIndex]);
228 +
229 return (
230 <Tooltip label={tooltipLabel}>
231 <FixedSizeList
@@ -184,6 +234,7 @@ function CommitFlamegraph({chartData, commitTree, height, width}: Props) {
234 itemCount={chartData.depth}
235 itemData={itemData}
236 itemSize={lineHeight}
237 + ref={listRef}
238 width={width}>
239 {CommitFlamegraphListItem}
240 </FixedSizeList>
packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.js
+6
@@ -29,9 +29,12 @@ type Props = {
29 function CommitFlamegraphListItem({data, index, style}: Props): React.Node {
30 const {
31 chartData,
32 + currentSearchMatchID,
33 + matchedFiberIDs,
34 onElementMouseEnter,
35 onElementMouseLeave,
36 scaleX,
37 + searchRegExp,
38 selectedChartNode,
39 selectedChartNodeIndex,
40 selectFiber,
@@ -115,12 +118,15 @@ function CommitFlamegraphListItem({data, index, style}: Props): React.Node {
118 <ChartNode
119 color={color}
120 height={lineHeight}
121 + isCurrentSearchMatch={id === currentSearchMatchID}
122 isDimmed={index < selectedChartNodeIndex}
123 + isSearchMatch={matchedFiberIDs.has(id)}
124 key={id}
125 label={label}
126 onClick={event => handleClick(event, id, name)}
127 onMouseEnter={() => handleMouseEnter(chartNode)}
128 onMouseLeave={handleMouseLeave}
129 + searchRegExp={searchRegExp}
130 textStyle={{color: textColor}}
131 width={nodeWidth}
132 x={nodeOffset - selectedNodeOffset}
packages/react-devtools-shared/src/devtools/views/Profiler/CommitRanked.js
+51 -2
@@ -8,7 +8,14 @@
8 */
9
10 import * as React from 'react';
11 -import {useCallback, useContext, useMemo, useState} from 'react';
11 +import {
12 + useCallback,
13 + useContext,
14 + useLayoutEffect,
15 + useMemo,
16 + useRef,
17 + useState,
18 +} from 'react';
19 import AutoSizer from 'react-virtualized-auto-sizer';
20 import {FixedSizeList} from 'react-window';
21 import {ProfilerContext} from './ProfilerContext';
@@ -16,6 +23,7 @@ import NoCommitData from './NoCommitData';
23 import CommitRankedListItem from './CommitRankedListItem';
24 import HoveredFiberInfo from './HoveredFiberInfo';
25 import {scale} from './utils';
26 +import {createRegExp} from '../utils';
27 import {StoreContext} from '../context';
28 import {SettingsContext} from '../Settings/SettingsContext';
29 import {useHighlightHostInstance} from '../hooks';
@@ -29,9 +37,12 @@ import type {CommitTree} from './types';
37
38 export type ItemData = {
39 chartData: ChartData,
40 + currentSearchMatchID: number | null,
41 + matchedFiberIDs: Set<number>,
42 onElementMouseEnter: (fiberData: TooltipFiberData) => void,
43 onElementMouseLeave: () => void,
44 scaleX: (value: number, fallbackValue: number) => number,
45 + searchRegExp: RegExp | null,
46 selectedFiberID: number | null,
47 selectedFiberIndex: number,
48 selectFiber: (id: number | null, name: string | null) => void,
@@ -98,7 +109,8 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
109 const [hoveredFiberData, setHoveredFiberData] =
110 useState<TooltipFiberData | null>(null);
111 const {lineHeight} = useContext(SettingsContext);
101 - const {selectedFiberID, selectFiber} = useContext(ProfilerContext);
112 + const {selectedFiberID, selectFiber, searchText, searchResults, searchIndex} =
113 + useContext(ProfilerContext);
114 const {highlightHostInstance, clearHighlightHostInstance} =
115 useHighlightHostInstance();
116
@@ -107,6 +119,20 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
119 [chartData, selectedFiberID],
120 );
121
122 + // Search highlighting (see CommitFlamegraph for details).
123 + const searchRegExp = useMemo(
124 + () => (searchText === '' ? null : createRegExp(searchText)),
125 + [searchText],
126 + );
127 + const matchedFiberIDs = useMemo(
128 + () => new Set(searchResults.map(result => result.id)),
129 + [searchResults],
130 + );
131 + const currentSearchMatchID =
132 + searchIndex >= 0 && searchIndex < searchResults.length
133 + ? searchResults[searchIndex].id
134 + : null;
135 +
136 const handleElementMouseEnter = useCallback(
137 ({id, name}: $FlowFixMe) => {
138 highlightHostInstance(id); // Highlight last hovered element.
@@ -123,9 +149,12 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
149 const itemData = useMemo<ItemData>(
150 () => ({
151 chartData,
152 + currentSearchMatchID,
153 + matchedFiberIDs,
154 onElementMouseEnter: handleElementMouseEnter,
155 onElementMouseLeave: handleElementMouseLeave,
156 scaleX: scale(0, chartData.nodes[selectedFiberIndex].value, 0, width),
157 + searchRegExp,
158 selectedFiberID,
159 selectedFiberIndex,
160 selectFiber,
@@ -133,8 +162,11 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
162 }),
163 [
164 chartData,
165 + currentSearchMatchID,
166 + matchedFiberIDs,
167 handleElementMouseEnter,
168 handleElementMouseLeave,
169 + searchRegExp,
170 selectedFiberID,
171 selectedFiberIndex,
172 selectFiber,
@@ -151,6 +183,22 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
183 [hoveredFiberData],
184 );
185
186 + // Scroll the selected fiber's row into view when the selection changes (e.g.
187 + // when navigating between search results). Selection is driven externally
188 + // (search nav in ProfilerContext, or a node click) and selectedFiberIndex is
189 + // derived here — no local event handler sets it — so we sync the imperative
190 + // scroll in a layout effect, which runs before paint to avoid a frame where
191 + // the scroll position lags the selection.
192 + const listRef = useRef<FixedSizeList | null>(null);
193 + const itemIsSelected = selectedFiberID !== null;
194 + useLayoutEffect(() => {
195 + // selectedFiberIndex falls back to 0 when nothing is selected, so only
196 + // scroll when a fiber is actually selected.
197 + if (itemIsSelected && listRef.current !== null) {
198 + listRef.current.scrollToItem(selectedFiberIndex, 'smart');
199 + }
200 + }, [itemIsSelected, selectedFiberIndex]);
201 +
202 return (
203 <Tooltip label={tooltipLabel}>
204 <FixedSizeList
@@ -159,6 +207,7 @@ function CommitRanked({chartData, commitTree, height, width}: Props) {
207 itemCount={chartData.nodes.length}
208 itemData={itemData}
209 itemSize={lineHeight}
210 + ref={listRef}
211 width={width}>
212 {CommitRankedListItem}
213 </FixedSizeList>
packages/react-devtools-shared/src/devtools/views/Profiler/CommitRankedListItem.js
+6
@@ -26,9 +26,12 @@ type Props = {
26 function CommitRankedListItem({data, index, style}: Props) {
27 const {
28 chartData,
29 + currentSearchMatchID,
30 + matchedFiberIDs,
31 onElementMouseEnter,
32 onElementMouseLeave,
33 scaleX,
34 + searchRegExp,
35 selectedFiberIndex,
36 selectFiber,
37 width,
@@ -66,12 +69,15 @@ function CommitRankedListItem({data, index, style}: Props) {
69 <ChartNode
70 color={getGradientColor(node.value / chartData.maxValue)}
71 height={lineHeight}
72 + isCurrentSearchMatch={node.id === currentSearchMatchID}
73 isDimmed={index < selectedFiberIndex}
74 + isSearchMatch={matchedFiberIDs.has(node.id)}
75 key={node.id}
76 label={node.label}
77 onClick={handleClick}
78 onMouseEnter={handleMouseEnter}
79 onMouseLeave={handleMouseLeave}
80 + searchRegExp={searchRegExp}
81 width={Math.max(minBarWidth, scaleX(node.value, width))}
82 x={0}
83 y={top}
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css
+27
@@ -160,3 +160,30 @@
160 .Link {
161 color: var(--color-button);
162 }
163 +
164 +.TimelineSearchInputContainer {
165 + flex: 1 1;
166 + display: flex;
167 + align-items: center;
168 +}
169 +
170 +/* Pinned to the bottom of the main view (like a Chrome-style find bar). */
171 +.SearchInputOverlay {
172 + position: absolute;
173 + left: 0;
174 + right: 0;
175 + bottom: 0;
176 + z-index: 3;
177 + display: flex;
178 + align-items: center;
179 + padding: 0.25rem 0.5rem;
180 + background-color: var(--color-background);
181 + border-top: 1px solid var(--color-border);
182 + box-shadow: 0 -2px 8px var(--color-shadow);
183 +}
184 +
185 +.LearnMoreLink {
186 + color: var(--color-link);
187 + margin-left: 0.25rem;
188 + margin-right: 0.25rem;
189 +}
packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js
+35 -2
@@ -11,6 +11,8 @@ import * as React from 'react';
11 import {Fragment, useContext, useEffect, useRef, useEffectEvent} from 'react';
12 import {ModalDialog} from '../ModalDialog';
13 import {ProfilerContext} from './ProfilerContext';
14 +import Button from '../Button';
15 +import ButtonIcon from '../ButtonIcon';
16 import TabBar from '../TabBar';
17 import ClearProfilingDataButton from './ClearProfilingDataButton';
18 import CommitFlamegraph from './CommitFlamegraph';
@@ -20,6 +22,7 @@ import RecordToggle from './RecordToggle';
22 import ReloadAndProfileButton from './ReloadAndProfileButton';
23 import ProfilingImportExportButtons from './ProfilingImportExportButtons';
24 import SnapshotSelector from './SnapshotSelector';
25 +import ProfilerSearchInput from './ProfilerSearchInput';
26 import SidebarCommitInfo from './SidebarCommitInfo';
27 import NoProfilingData from './NoProfilingData';
28 import RecordingInProgress from './RecordingInProgress';
@@ -52,6 +55,9 @@ function Profiler(_: {}) {
55 stopProfiling,
56 selectPrevCommitIndex,
57 selectNextCommitIndex,
58 + isSearchInputVisible,
59 + showSearchInput,
60 + hideSearchInput,
61 } = useContext(ProfilerContext);
62
63 const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
@@ -65,6 +71,16 @@ function Profiler(_: {}) {
71 }
72 event.preventDefault();
73 event.stopPropagation();
74 + } else if (didRecordCommits && correctModifier && event.key === 'f') {
75 + // Cmd+F (Mac) or Ctrl+F (Windows/Linux) to search components in the commit
76 + showSearchInput();
77 + event.preventDefault();
78 + event.stopPropagation();
79 + } else if (isSearchInputVisible && event.key === 'Escape') {
80 + // Escape closes the search input.
81 + hideSearchInput();
82 + event.preventDefault();
83 + event.stopPropagation();
84 } else if (didRecordCommits && selectedCommitIndex !== null) {
85 // Cmd+Left/Right (Mac) or Ctrl+Left/Right (Windows/Linux) to navigate commits
86 if (
@@ -88,9 +104,11 @@ function Profiler(_: {}) {
104 return;
105 }
106 const ownerWindow = div.ownerDocument.defaultView;
91 - ownerWindow.addEventListener('keydown', handleKeyDown);
107 + // Capture phase: Cmd/Ctrl+F is a reserved browser shortcut (Find), so we
108 + // must intercept it before the browser to open our own search instead.
109 + ownerWindow.addEventListener('keydown', handleKeyDown, true);
110 return () => {
93 - ownerWindow.removeEventListener('keydown', handleKeyDown);
111 + ownerWindow.removeEventListener('keydown', handleKeyDown, true);
112 };
113 }, []);
114
@@ -163,11 +181,26 @@ function Profiler(_: {}) {
181 {didRecordCommits && (
182 <Fragment>
183 <div className={styles.VRule} />
184 + <Button
185 + onClick={
186 + isSearchInputVisible ? hideSearchInput : showSearchInput
187 + }
188 + title={`Search components in this commit (${
189 + isMac ? '⌘' : 'Ctrl+'
190 + }F)`}
191 + data-testname="ProfilerSearchButton">
192 + <ButtonIcon type="find" />
193 + </Button>
194 <SnapshotSelector />
195 </Fragment>
196 )}
197 </div>
198 <div className={styles.Content}>
199 + {didRecordCommits && isSearchInputVisible && (
200 + <div className={styles.SearchInputOverlay}>
201 + <ProfilerSearchInput />
202 + </div>
203 + )}
204 {view}
205 <ModalDialog />
206 </div>
packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.js
+194 -1
@@ -14,6 +14,7 @@ import {
14 createContext,
15 useCallback,
16 useContext,
17 + useDeferredValue,
18 useMemo,
19 useState,
20 useEffect,
@@ -24,13 +25,53 @@ import {
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
30 -import type {CommitDataFrontend, ProfilingDataFrontend} from './types';
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,
@@ -80,6 +121,21 @@ export type Context = {
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>(
@@ -144,6 +200,12 @@ function ProfilerContextController({children}: Props): React.Node {
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);
@@ -255,6 +317,108 @@ function ProfilerContextController({children}: Props): React.Node {
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',
@@ -266,6 +430,11 @@ function ProfilerContextController({children}: Props): React.Node {
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
@@ -314,6 +483,18 @@ function ProfilerContextController({children}: Props): React.Node {
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,
@@ -345,6 +526,18 @@ function ProfilerContextController({children}: Props): React.Node {
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
packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerSearchInput.js new
+46
@@ -0,0 +1,46 @@
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 * as React from 'react';
11 +import {useContext} from 'react';
12 +
13 +import SearchInput from 'react-devtools-shared/src/devtools/views/SearchInput';
14 +import {ProfilerContext} from './ProfilerContext';
15 +
16 +export default function ProfilerSearchInput(): React.Node {
17 + const {
18 + searchText,
19 + setSearchText,
20 + searchResults,
21 + searchIndex,
22 + searchIsPending,
23 + goToNextSearchResult,
24 + goToPreviousSearchResult,
25 + goToSearchResult,
26 + hideSearchInput,
27 + } = useContext(ProfilerContext);
28 +
29 + return (
30 + <SearchInput
31 + autoFocus={true}
32 + goToNextResult={goToNextSearchResult}
33 + goToPreviousResult={goToPreviousSearchResult}
34 + goToResult={goToSearchResult}
35 + iconType="find"
36 + isPending={searchIsPending}
37 + onClose={hideSearchInput}
38 + placeholder="Search this commit (text or /regex/)"
39 + search={setSearchText}
40 + searchIndex={searchIndex}
41 + searchResultsCount={searchResults.length}
42 + searchText={searchText}
43 + testName="ProfilerSearchInput"
44 + />
45 + );
46 +}
packages/react-devtools-shared/src/devtools/views/SearchInput.css
+18
@@ -28,6 +28,24 @@
28 white-space: pre;
29 }
30
31 +/* Shown while the (deferred) search is still computing matches. */
32 +.Spinner {
33 + flex: 0 0 auto;
34 + width: 0.75rem;
35 + height: 0.75rem;
36 + margin: 0 0.25rem;
37 + border: 2px solid var(--color-border);
38 + border-top-color: var(--color-dim);
39 + border-radius: 50%;
40 + animation: SearchInput-spin 0.6s linear infinite;
41 +}
42 +
43 +@keyframes SearchInput-spin {
44 + to {
45 + transform: rotate(360deg);
46 + }
47 +}
48 +
49 .IndexInput {
50 color: var(--color-text);
51 font-size: var(--font-size-sans-normal);
packages/react-devtools-shared/src/devtools/views/SearchInput.js
+45 -17
@@ -17,13 +17,18 @@ import {useEffect, useRef, useState} from 'react';
17 import Button from './Button';
18 import ButtonIcon from './ButtonIcon';
19 import Icon from './Icon';
20 +import type {IconType} from './Icon';
21 import AutoSizeInput from './Components/NativeStyleEditor/AutoSizeInput';
22
23 import styles from './SearchInput.css';
24
25 type Props = {
26 + autoFocus?: boolean,
27 goToNextResult: () => void,
28 goToPreviousResult: () => void,
29 + iconType?: IconType,
30 + isPending?: boolean,
31 + onClose?: () => void,
32 goToResult: (index: number) => void,
33 placeholder: string,
34 search: (text: string) => void,
@@ -34,8 +39,12 @@ type Props = {
39 };
40
41 export default function SearchInput({
42 + autoFocus,
43 goToNextResult,
44 goToPreviousResult,
45 + iconType = 'search',
46 + isPending,
47 + onClose,
48 goToResult,
49 placeholder,
50 search,
@@ -96,26 +105,28 @@ export default function SearchInput({
105
106 // Auto-focus search input
107 useEffect(() => {
99 - if (inputRef.current === null) {
108 + const input = inputRef.current;
109 + if (input === null) {
110 return () => {};
111 }
112
113 + if (autoFocus) {
114 + input.focus();
115 + }
116 +
117 const handleKeyDown = (event: KeyboardEvent) => {
118 const {key, metaKey} = event;
119 if (key === 'f' && metaKey) {
106 - const inputElement = inputRef.current;
107 - if (inputElement !== null) {
108 - inputElement.focus();
109 - event.preventDefault();
110 - event.stopPropagation();
111 - }
120 + input.focus();
121 + event.preventDefault();
122 + event.stopPropagation();
123 }
124 };
125
126 // It's important to listen to the ownerDocument to support the browser extension.
127 // Here we use portals to render individual tabs (e.g. Profiler),
128 // and the root document might belong to a different window.
118 - const ownerDocumentElement = inputRef.current.ownerDocument.documentElement;
129 + const ownerDocumentElement = input.ownerDocument.documentElement;
130 if (ownerDocumentElement === null) {
131 return;
132 }
@@ -123,11 +134,11 @@ export default function SearchInput({
134
135 return () =>
136 ownerDocumentElement.removeEventListener('keydown', handleKeyDown);
126 - }, []);
137 + }, [autoFocus]);
138
139 return (
140 <div className={styles.SearchInput} data-testname={testName}>
130 - <Icon className={styles.InputIcon} type="search" />
141 + <Icon className={styles.InputIcon} type={iconType} />
142 <input
143 data-testname={testName ? `${testName}-Input` : undefined}
144 className={styles.Input}
@@ -137,6 +148,13 @@ export default function SearchInput({
148 ref={inputRef}
149 value={searchText}
150 />
151 + {isPending === true && (
152 + <span
153 + className={styles.Spinner}
154 + data-testname={testName ? `${testName}-Spinner` : undefined}
155 + title="Searching…"
156 + />
157 + )}
158 {!!searchText && (
159 <React.Fragment>
160 <span
@@ -183,15 +201,25 @@ export default function SearchInput({
201 }>
202 <ButtonIcon type="down" />
203 </Button>
186 - <Button
187 - data-testname={testName ? `${testName}-ResetButton` : undefined}
188 - disabled={!searchText}
189 - onClick={resetSearch}
190 - title="Reset search">
191 - <ButtonIcon type="close" />
192 - </Button>
204 + {onClose == null && (
205 + <Button
206 + data-testname={testName ? `${testName}-ResetButton` : undefined}
207 + disabled={!searchText}
208 + onClick={resetSearch}
209 + title="Reset search">
210 + <ButtonIcon type="close" />
211 + </Button>
212 + )}
213 </React.Fragment>
214 )}
215 + {onClose != null && (
216 + <Button
217 + data-testname={testName ? `${testName}-CloseButton` : undefined}
218 + onClick={onClose}
219 + title="Close search (Esc)">
220 + <ButtonIcon type="close" />
221 + </Button>
222 + )}
223 </div>
224 );
225 }