@samitouri / QOS-React / commits / 5afc23a2b6

[DevTools] Make component search results directly navigable (#36786)

## Summary In a large react app, especially when components having similar starting names like Table, TableColumn, TableCell, TableRow all together 100+ components when rendered in a virtualized table. Traversing the search result is sometimes difficult with scroll The component tree search only let you step through matches one at a time (Enter / Shift+Enter). In large apps with many similarly-named components (Table, TableRow, TableCell, ...) a search can return 100+ matches in a virtualized list, making a specific match tedious to reach. - the result counter is an editable, live-scrubbing index field: typing a number scrolls to that match as you type (clamped to range) - Fixes re-search getting stuck, clearing the box and retyping the same term while a match was still selected snapped back to that same component. It now advances to the next match (find-next semantics). ## How did you test this change? Adds a SearchableTable example to the DevTools shell and unit tests for the new action and the retype behavior. https://github.com/user-attachments/assets/7ea9801a-7bcb-4e8f-bf73-a5307a0fdbae cc @hoxyq Let me know what do you feel about this feature, if its helpful for devtools.

BIKI DAS committed Jul 6, 2026 at 19:29 UTC 5afc23a2b6784d2c085a67f2a3cf9d84e7e6c301
8 files changed +398 -12
packages/react-devtools-inline/__tests__/__e2e__/components.test.js
+12 -4
@@ -220,12 +220,20 @@ test.describe('Components', () => {
220 window.REACT_DOM_DEVTOOLS;
221 const container = document.getElementById('devtools');
222
223 - const element = findAllNodes(container, [
223 + // The current result index is an editable input, so its value is not
224 + // part of the wrapper's innerText. Combine the input value with the
225 + // total result count to reconstruct the "X | Y" label.
226 + const indexInput = findAllNodes(container, [
227 + createTestNameSelector('ComponentSearchInput-ResultIndexInput'),
228 + ])[0];
229 + const resultsCount = findAllNodes(container, [
230 createTestNameSelector('ComponentSearchInput-ResultsCount'),
231 ])[0];
226 - return element !== undefined
227 - ? element.innerText === expectedElementText
228 - : false;
232 + if (indexInput === undefined || resultsCount === undefined) {
233 + return false;
234 + }
235 + const totalCount = resultsCount.innerText.replace(/[^0-9]/g, '');
236 + return `${indexInput.value} | ${totalCount}` === expectedElementText;
237 }, text);
238 }
239
packages/react-devtools-shared/src/__tests__/treeContext-test.js
+166
@@ -1078,6 +1078,172 @@ describe('TreeListContext', () => {
1078 `);
1079 });
1080
1081 + it('should jump directly to a specific search result by index', () => {
1082 + const Foo = () => null;
1083 + const Bar = () => null;
1084 + const Baz = () => null;
1085 +
1086 + utils.act(() =>
1087 + render(
1088 + <React.Fragment>
1089 + <Foo />
1090 + <Baz />
1091 + <Bar />
1092 + <Baz />
1093 + </React.Fragment>,
1094 + ),
1095 + );
1096 +
1097 + let renderer;
1098 + utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
1099 +
1100 + // search for "ba" (matches both <Baz> elements and <Bar>)
1101 + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
1102 + utils.act(() => renderer.update(<Contexts />));
1103 + expect(state).toMatchInlineSnapshot(`
1104 + [root]
1105 + <Foo>
1106 + → <Baz>
1107 + <Bar>
1108 + <Baz>
1109 + `);
1110 +
1111 + // jump directly to the third result
1112 + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 2}));
1113 + utils.act(() => renderer.update(<Contexts />));
1114 + expect(state).toMatchInlineSnapshot(`
1115 + [root]
1116 + <Foo>
1117 + <Baz>
1118 + <Bar>
1119 + → <Baz>
1120 + `);
1121 +
1122 + // jump directly back to the first result
1123 + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
1124 + utils.act(() => renderer.update(<Contexts />));
1125 + expect(state).toMatchInlineSnapshot(`
1126 + [root]
1127 + <Foo>
1128 + → <Baz>
1129 + <Bar>
1130 + <Baz>
1131 + `);
1132 +
1133 + // out-of-range indices are clamped to the valid range
1134 + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 99}));
1135 + utils.act(() => renderer.update(<Contexts />));
1136 + expect(state).toMatchInlineSnapshot(`
1137 + [root]
1138 + <Foo>
1139 + <Baz>
1140 + <Bar>
1141 + → <Baz>
1142 + `);
1143 +
1144 + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: -5}));
1145 + utils.act(() => renderer.update(<Contexts />));
1146 + expect(state).toMatchInlineSnapshot(`
1147 + [root]
1148 + <Foo>
1149 + → <Baz>
1150 + <Bar>
1151 + <Baz>
1152 + `);
1153 + });
1154 +
1155 + it('should do nothing when jumping to a result with no search matches', () => {
1156 + const Foo = () => null;
1157 + const Bar = () => null;
1158 + const Baz = () => null;
1159 +
1160 + utils.act(() =>
1161 + render(
1162 + <React.Fragment>
1163 + <Foo />
1164 + <Baz />
1165 + <Bar />
1166 + <Baz />
1167 + </React.Fragment>,
1168 + ),
1169 + );
1170 +
1171 + let renderer;
1172 + utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
1173 +
1174 + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'nomatch'}));
1175 + utils.act(() => renderer.update(<Contexts />));
1176 + expect(state.searchResults).toHaveLength(0);
1177 + expect(state.searchIndex).toBe(null);
1178 +
1179 + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
1180 + utils.act(() => renderer.update(<Contexts />));
1181 + expect(state.searchIndex).toBe(null);
1182 + expect(state.inspectedElementID).toBe(null);
1183 + expect(state).toMatchInlineSnapshot(`
1184 + [root]
1185 + <Foo>
1186 + <Baz>
1187 + <Bar>
1188 + <Baz>
1189 + `);
1190 + });
1191 +
1192 + it('should advance past the selected result when retyping the same search', () => {
1193 + const Foo = () => null;
1194 + const Bar = () => null;
1195 + const Baz = () => null;
1196 +
1197 + utils.act(() =>
1198 + render(
1199 + <React.Fragment>
1200 + <Foo />
1201 + <Baz />
1202 + <Bar />
1203 + <Baz />
1204 + </React.Fragment>,
1205 + ),
1206 + );
1207 +
1208 + let renderer;
1209 + utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
1210 +
1211 + // search for "ba" and step to the second result (<Bar>)
1212 + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
1213 + utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
1214 + utils.act(() => renderer.update(<Contexts />));
1215 + expect(state).toMatchInlineSnapshot(`
1216 + [root]
1217 + <Foo>
1218 + <Baz>
1219 + → <Bar>
1220 + <Baz>
1221 + `);
1222 +
1223 + // clear the search; the matched element stays selected
1224 + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}));
1225 + utils.act(() => renderer.update(<Contexts />));
1226 + expect(state).toMatchInlineSnapshot(`
1227 + [root]
1228 + <Foo>
1229 + <Baz>
1230 + → <Bar>
1231 + <Baz>
1232 + `);
1233 +
1234 + // retype the same query: instead of snapping back to the still-selected
1235 + // <Bar>, the search advances to the next match (find-next semantics)
1236 + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
1237 + utils.act(() => renderer.update(<Contexts />));
1238 + expect(state).toMatchInlineSnapshot(`
1239 + [root]
1240 + <Foo>
1241 + <Baz>
1242 + <Bar>
1243 + → <Baz>
1244 + `);
1245 + });
1246 +
1247 it('should add newly mounted elements to the search results set if they match the current text', async () => {
1248 const Foo = () => null;
1249 const Bar = () => null;
packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js
+6
@@ -36,11 +36,17 @@ export default function ComponentSearchInput(): React.Node {
36 () => transitionDispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}),
37 [transitionDispatch],
38 );
39 + const goToResult = useCallback(
40 + (index: number) =>
41 + transitionDispatch({type: 'GO_TO_SEARCH_RESULT', payload: index}),
42 + [transitionDispatch],
43 + );
44
45 return (
46 <SearchInput
47 goToNextResult={goToNextResult}
48 goToPreviousResult={goToPreviousResult}
49 + goToResult={goToResult}
50 placeholder="Search (text or /regex/)"
51 search={search}
52 searchIndex={searchIndex}
packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
+47 -6
@@ -72,6 +72,10 @@ type ACTION_GO_TO_NEXT_SEARCH_RESULT = {
72 type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
73 type: 'GO_TO_PREVIOUS_SEARCH_RESULT',
74 };
75 +type ACTION_GO_TO_SEARCH_RESULT = {
76 + type: 'GO_TO_SEARCH_RESULT',
77 + payload: number,
78 +};
79 type ACTION_HANDLE_STORE_MUTATION = {
80 type: 'HANDLE_STORE_MUTATION',
81 payload: [Array<number>, Map<number, number>, null | Element['id']],
@@ -129,6 +133,7 @@ type ACTION_SET_SEARCH_TEXT = {
133 type Action =
134 | ACTION_GO_TO_NEXT_SEARCH_RESULT
135 | ACTION_GO_TO_PREVIOUS_SEARCH_RESULT
136 + | ACTION_GO_TO_SEARCH_RESULT
137 | ACTION_HANDLE_STORE_MUTATION
138 | ACTION_RESET_OWNER_STACK
139 | ACTION_SELECT_CHILD_ELEMENT_IN_TREE
@@ -525,6 +530,19 @@ function reduceSearchState(store: Store, state: State, action: Action): State {
530 : numPrevSearchResults - 1;
531 }
532 break;
533 + case 'GO_TO_SEARCH_RESULT':
534 + if (numPrevSearchResults > 0) {
535 + didRequestSearch = true;
536 + // Jump directly to a specific result (0-based), clamped to range.
537 + // This lets users skip past large virtualized lists instead of
538 + // stepping through results one at a time.
539 + const targetIndex = (action: ACTION_GO_TO_SEARCH_RESULT).payload;
540 + searchIndex = Math.max(
541 + 0,
542 + Math.min(targetIndex, numPrevSearchResults - 1),
543 + );
544 + }
545 + break;
546 case 'HANDLE_STORE_MUTATION':
547 if (searchText !== '') {
548 const [addedElementIDs, removedElementIDs] = (
@@ -630,13 +648,21 @@ function reduceSearchState(store: Store, state: State, action: Action): State {
648 if (searchText !== prevSearchText) {
649 // $FlowFixMe[incompatible-type]
650 const newSearchIndex = searchResults.indexOf(inspectedElementID);
633 - if (newSearchIndex === -1) {
634 - // Only move the selection if the new query
635 - // doesn't match the current selection anymore.
651 + if (prevSearchText === '') {
652 + // Starting a fresh search (e.g. after clearing the box). Honor the index
653 + // computed above, which uses "find next" semantics so that retyping the
654 + // same query advances past the still-selected result instead of snapping
655 + // back to it.
656 + if (searchIndex !== null) {
657 + didRequestSearch = true;
658 + }
659 + } else if (newSearchIndex === -1) {
660 + // Refining an existing query and the current selection no longer matches,
661 + // so move the selection to the nearest result.
662 didRequestSearch = true;
663 } else {
638 - // Selected item still matches the new search query.
639 - // Adjust the index to reflect its position in new results.
664 + // Refining an existing query and the current selection still matches.
665 + // Keep it selected and adjust the index to its position in new results.
666 searchIndex = newSearchIndex;
667 }
668 }
@@ -910,6 +936,7 @@ function TreeContextController({
936 switch (type) {
937 case 'GO_TO_NEXT_SEARCH_RESULT':
938 case 'GO_TO_PREVIOUS_SEARCH_RESULT':
939 + case 'GO_TO_SEARCH_RESULT':
940 case 'HANDLE_STORE_MUTATION':
941 case 'RESET_OWNER_STACK':
942 case 'SELECT_ELEMENT_AT_INDEX':
@@ -1079,9 +1106,23 @@ function getNearestResultIndex(
1106 searchResults: Array<number>,
1107 inspectedElementIndex: number,
1108 ): number {
1109 + // When the currently selected element is itself a match for the new query
1110 + // (e.g. you cleared the search and retyped the same text while a result was
1111 + // still selected), advance to the *next* match instead of snapping back to
1112 + // the same component. This mirrors "find next" semantics in browsers/editors
1113 + // and avoids the search feeling stuck on the same result.
1114 + const selectedIsResult = searchResults.some(
1115 + id => store.getIndexOfElementID(id) === inspectedElementIndex,
1116 + );
1117 +
1118 const index = searchResults.findIndex(id => {
1119 const innerIndex = store.getIndexOfElementID(id);
1084 - return innerIndex !== null && innerIndex >= inspectedElementIndex;
1120 + if (innerIndex === null) {
1121 + return false;
1122 + }
1123 + return selectedIsResult
1124 + ? innerIndex > inspectedElementIndex
1125 + : innerIndex >= inspectedElementIndex;
1126 });
1127
1128 return index === -1 ? 0 : index;
packages/react-devtools-shared/src/devtools/views/SearchInput.css
+29
@@ -28,6 +28,35 @@
28 white-space: pre;
29 }
30
31 +.IndexInput {
32 + color: var(--color-text);
33 + font-size: var(--font-size-sans-normal);
34 + font-family: inherit;
35 + text-align: center;
36 + background: none;
37 + /* A visible border so it's clear this number can be edited. */
38 + border: 1px solid var(--color-border);
39 + border-radius: 0.125rem;
40 + outline: none;
41 + padding: 0 0.25rem;
42 + margin: 0;
43 + /* Keep a floor so the box doesn't shrink/jitter as the digit count changes. */
44 + min-width: 1.5ch;
45 +}
46 +
47 +/* AutoSizeInput sizes the element width to fit the text exactly (assuming no
48 + padding/border). content-box makes our padding + border add around that
49 + width rather than eating into it and clipping digits. The descendant
50 + selector raises specificity above the global `.DevTools *` border-box rule,
51 + which otherwise wins by source order at equal specificity. */
52 +.IndexLabel .IndexInput {
53 + box-sizing: content-box;
54 +}
55 +
56 +.IndexInput:focus {
57 + background-color: var(--color-button-background-focus);
58 +}
59 +
60 .LeftVRule{
61 height: 20px;
62 width: 1px;
packages/react-devtools-shared/src/devtools/views/SearchInput.js
+55 -2
@@ -7,17 +7,24 @@
7 * @flow
8 */
9
10 +import typeof {
11 + SyntheticEvent,
12 + SyntheticKeyboardEvent,
13 +} from 'react-dom-bindings/src/events/SyntheticEvent';
14 +
15 import * as React from 'react';
11 -import {useEffect, useRef} from 'react';
16 +import {useEffect, useRef, useState} from 'react';
17 import Button from './Button';
18 import ButtonIcon from './ButtonIcon';
19 import Icon from './Icon';
20 +import AutoSizeInput from './Components/NativeStyleEditor/AutoSizeInput';
21
22 import styles from './SearchInput.css';
23
24 type Props = {
25 goToNextResult: () => void,
26 goToPreviousResult: () => void,
27 + goToResult: (index: number) => void,
28 placeholder: string,
29 search: (text: string) => void,
30 searchIndex: number,
@@ -29,6 +36,7 @@ type Props = {
36 export default function SearchInput({
37 goToNextResult,
38 goToPreviousResult,
39 + goToResult,
40 placeholder,
41 search,
42 searchIndex,
@@ -38,6 +46,37 @@ export default function SearchInput({
46 }: Props): React.Node {
47 const inputRef = useRef<HTMLInputElement | null>(null);
48
49 + const [indexDraft, setIndexDraft] = useState<string | null>(null);
50 + const currentResultNumber = Math.min(searchIndex + 1, searchResultsCount);
51 + const indexValue =
52 + indexDraft !== null ? indexDraft : String(currentResultNumber);
53 +
54 + const handleIndexChange = (event: SyntheticEvent) => {
55 + // Only digits are meaningful here; strip anything else as it's typed.
56 + const raw = event.currentTarget.value.replace(/[^0-9]/g, '');
57 +
58 + if (raw === '' || searchResultsCount === 0) {
59 + setIndexDraft(raw);
60 + return;
61 + }
62 +
63 + // Clamp into [1, searchResultsCount] so the field never displays an
64 + // out-of-range value, then live-preview by scrolling to that result.
65 + const clamped = Math.max(
66 + 1,
67 + Math.min(parseInt(raw, 10), searchResultsCount),
68 + );
69 + setIndexDraft(String(clamped));
70 + goToResult(clamped - 1);
71 + };
72 + const handleIndexBlur = () => setIndexDraft(null);
73 + const handleIndexKeyDown = (event: SyntheticKeyboardEvent) => {
74 + if (event.key === 'Enter' || event.key === 'Escape') {
75 + event.preventDefault();
76 + event.currentTarget.blur();
77 + }
78 + };
79 +
80 const resetSearch = () => search('');
81
82 // $FlowFixMe[missing-local-annot]
@@ -103,7 +142,21 @@ export default function SearchInput({
142 <span
143 className={styles.IndexLabel}
144 data-testname={testName ? `${testName}-ResultsCount` : undefined}>
106 - {Math.min(searchIndex + 1, searchResultsCount)} |{' '}
145 + <AutoSizeInput
146 + className={styles.IndexInput}
147 + testName={testName ? `${testName}-ResultIndexInput` : undefined}
148 + type="text"
149 + inputMode="numeric"
150 + pattern="[0-9]*"
151 + aria-label="Go to search result number"
152 + title="Go to search result number"
153 + disabled={searchResultsCount === 0}
154 + onBlur={handleIndexBlur}
155 + onChange={handleIndexChange}
156 + onKeyDown={handleIndexKeyDown}
157 + value={indexValue}
158 + />
159 + {' | '}
160 {searchResultsCount}
161 </span>
162 <div className={styles.LeftVRule} />
packages/react-devtools-shell/src/app/SearchableTable/index.js new
+81
@@ -0,0 +1,81 @@
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 {Fragment} from 'react';
12 +
13 +// A large tree of similarly-named components (Table, TableRow, TableCell, ...).
14 +// This mirrors a real virtualized table and is meant for exercising the
15 +// component-tree search box in DevTools:
16 +// 1. Open DevTools and search "Table" — there are 100+ matches.
17 +// 2. Type a number into the result-index field (left of "| N") to jump
18 +// directly to a specific match instead of scrolling/pressing Enter.
19 +// 3. Select a match, clear the search, then retype the same text — the
20 +// search advances to the *next* match instead of snapping back.
21 +
22 +const ROWS = 25;
23 +const COLS = 4;
24 +
25 +function TableCell({row, col}: {row: number, col: number}): React.Node {
26 + return <td>{`r${row}c${col}`}</td>;
27 +}
28 +
29 +function TableColumnHeader({col}: {col: number}): React.Node {
30 + return <th>{`Column ${col}`}</th>;
31 +}
32 +
33 +function TableRow({row}: {row: number}): React.Node {
34 + return (
35 + <tr>
36 + {Array.from({length: COLS}, (_, col) => (
37 + <TableCell key={col} row={row} col={col} />
38 + ))}
39 + </tr>
40 + );
41 +}
42 +
43 +function TableHeaderRow(): React.Node {
44 + return (
45 + <tr>
46 + {Array.from({length: COLS}, (_, col) => (
47 + <TableColumnHeader key={col} col={col} />
48 + ))}
49 + </tr>
50 + );
51 +}
52 +
53 +function TableBody(): React.Node {
54 + return (
55 + <tbody>
56 + {Array.from({length: ROWS}, (_, row) => (
57 + <TableRow key={row} row={row} />
58 + ))}
59 + </tbody>
60 + );
61 +}
62 +
63 +function Table(): React.Node {
64 + return (
65 + <table>
66 + <thead>
67 + <TableHeaderRow />
68 + </thead>
69 + <TableBody />
70 + </table>
71 + );
72 +}
73 +
74 +export default function SearchableTable(): React.Node {
75 + return (
76 + <Fragment>
77 + <h1>Searchable Table</h1>
78 + <Table />
79 + </Fragment>
80 + );
81 +}
packages/react-devtools-shell/src/app/index.js
+2
@@ -20,6 +20,7 @@ import ErrorBoundaries from './ErrorBoundaries';
20 import PartiallyStrictApp from './PartiallyStrictApp';
21 import Segments from './Segments';
22 import SuspenseTree from './SuspenseTree';
23 +import SearchableTable from './SearchableTable';
24 import ActivityTree from './ActivityTree';
25 import TraceUpdatesTest from './TraceUpdatesTest';
26 import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
@@ -113,6 +114,7 @@ function mountTestApp() {
114 mountApp(Toggle);
115 mountApp(ErrorBoundaries);
116 mountApp(SuspenseTree);
117 + mountApp(SearchableTable);
118 mountApp(DeeplyNestedComponents);
119 mountApp(Iframe);
120 mountApp(ActivityTree);