[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.
This commit is contained in:
BIKI DAS
2026-07-06 14:59:51 +01:00
committed by GitHub
parent e01aa00cd7
commit aa4358de54
8 changed files with 398 additions and 12 deletions
@@ -220,12 +220,20 @@ test.describe('Components', () => {
window.REACT_DOM_DEVTOOLS;
const container = document.getElementById('devtools');
const element = findAllNodes(container, [
// The current result index is an editable input, so its value is not
// part of the wrapper's innerText. Combine the input value with the
// total result count to reconstruct the "X | Y" label.
const indexInput = findAllNodes(container, [
createTestNameSelector('ComponentSearchInput-ResultIndexInput'),
])[0];
const resultsCount = findAllNodes(container, [
createTestNameSelector('ComponentSearchInput-ResultsCount'),
])[0];
return element !== undefined
? element.innerText === expectedElementText
: false;
if (indexInput === undefined || resultsCount === undefined) {
return false;
}
const totalCount = resultsCount.innerText.replace(/[^0-9]/g, '');
return `${indexInput.value} | ${totalCount}` === expectedElementText;
}, text);
}
@@ -1078,6 +1078,172 @@ describe('TreeListContext', () => {
`);
});
it('should jump directly to a specific search result by index', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
// search for "ba" (matches both <Baz> elements and <Bar>)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);
// jump directly to the third result
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 2}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);
// jump directly back to the first result
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);
// out-of-range indices are clamped to the valid range
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 99}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: -5}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);
});
it('should do nothing when jumping to a result with no search matches', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'nomatch'}));
utils.act(() => renderer.update(<Contexts />));
expect(state.searchResults).toHaveLength(0);
expect(state.searchIndex).toBe(null);
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
utils.act(() => renderer.update(<Contexts />));
expect(state.searchIndex).toBe(null);
expect(state.inspectedElementID).toBe(null);
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
<Baz>
`);
});
it('should advance past the selected result when retyping the same search', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
// search for "ba" and step to the second result (<Bar>)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
→ <Bar>
<Baz>
`);
// clear the search; the matched element stays selected
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
→ <Bar>
<Baz>
`);
// retype the same query: instead of snapping back to the still-selected
// <Bar>, the search advances to the next match (find-next semantics)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);
});
it('should add newly mounted elements to the search results set if they match the current text', async () => {
const Foo = () => null;
const Bar = () => null;
@@ -36,11 +36,17 @@ export default function ComponentSearchInput(): React.Node {
() => transitionDispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}),
[transitionDispatch],
);
const goToResult = useCallback(
(index: number) =>
transitionDispatch({type: 'GO_TO_SEARCH_RESULT', payload: index}),
[transitionDispatch],
);
return (
<SearchInput
goToNextResult={goToNextResult}
goToPreviousResult={goToPreviousResult}
goToResult={goToResult}
placeholder="Search (text or /regex/)"
search={search}
searchIndex={searchIndex}
@@ -72,6 +72,10 @@ type ACTION_GO_TO_NEXT_SEARCH_RESULT = {
type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
type: 'GO_TO_PREVIOUS_SEARCH_RESULT',
};
type ACTION_GO_TO_SEARCH_RESULT = {
type: 'GO_TO_SEARCH_RESULT',
payload: number,
};
type ACTION_HANDLE_STORE_MUTATION = {
type: 'HANDLE_STORE_MUTATION',
payload: [Array<number>, Map<number, number>, null | Element['id']],
@@ -129,6 +133,7 @@ type ACTION_SET_SEARCH_TEXT = {
type Action =
| ACTION_GO_TO_NEXT_SEARCH_RESULT
| ACTION_GO_TO_PREVIOUS_SEARCH_RESULT
| ACTION_GO_TO_SEARCH_RESULT
| ACTION_HANDLE_STORE_MUTATION
| ACTION_RESET_OWNER_STACK
| ACTION_SELECT_CHILD_ELEMENT_IN_TREE
@@ -525,6 +530,19 @@ function reduceSearchState(store: Store, state: State, action: Action): State {
: numPrevSearchResults - 1;
}
break;
case 'GO_TO_SEARCH_RESULT':
if (numPrevSearchResults > 0) {
didRequestSearch = true;
// Jump directly to a specific result (0-based), clamped to range.
// This lets users skip past large virtualized lists instead of
// stepping through results one at a time.
const targetIndex = (action: ACTION_GO_TO_SEARCH_RESULT).payload;
searchIndex = Math.max(
0,
Math.min(targetIndex, numPrevSearchResults - 1),
);
}
break;
case 'HANDLE_STORE_MUTATION':
if (searchText !== '') {
const [addedElementIDs, removedElementIDs] = (
@@ -630,13 +648,21 @@ function reduceSearchState(store: Store, state: State, action: Action): State {
if (searchText !== prevSearchText) {
// $FlowFixMe[incompatible-type]
const newSearchIndex = searchResults.indexOf(inspectedElementID);
if (newSearchIndex === -1) {
// Only move the selection if the new query
// doesn't match the current selection anymore.
if (prevSearchText === '') {
// Starting a fresh search (e.g. after clearing the box). Honor the index
// computed above, which uses "find next" semantics so that retyping the
// same query advances past the still-selected result instead of snapping
// back to it.
if (searchIndex !== null) {
didRequestSearch = true;
}
} else if (newSearchIndex === -1) {
// Refining an existing query and the current selection no longer matches,
// so move the selection to the nearest result.
didRequestSearch = true;
} else {
// Selected item still matches the new search query.
// Adjust the index to reflect its position in new results.
// Refining an existing query and the current selection still matches.
// Keep it selected and adjust the index to its position in new results.
searchIndex = newSearchIndex;
}
}
@@ -910,6 +936,7 @@ function TreeContextController({
switch (type) {
case 'GO_TO_NEXT_SEARCH_RESULT':
case 'GO_TO_PREVIOUS_SEARCH_RESULT':
case 'GO_TO_SEARCH_RESULT':
case 'HANDLE_STORE_MUTATION':
case 'RESET_OWNER_STACK':
case 'SELECT_ELEMENT_AT_INDEX':
@@ -1079,9 +1106,23 @@ function getNearestResultIndex(
searchResults: Array<number>,
inspectedElementIndex: number,
): number {
// When the currently selected element is itself a match for the new query
// (e.g. you cleared the search and retyped the same text while a result was
// still selected), advance to the *next* match instead of snapping back to
// the same component. This mirrors "find next" semantics in browsers/editors
// and avoids the search feeling stuck on the same result.
const selectedIsResult = searchResults.some(
id => store.getIndexOfElementID(id) === inspectedElementIndex,
);
const index = searchResults.findIndex(id => {
const innerIndex = store.getIndexOfElementID(id);
return innerIndex !== null && innerIndex >= inspectedElementIndex;
if (innerIndex === null) {
return false;
}
return selectedIsResult
? innerIndex > inspectedElementIndex
: innerIndex >= inspectedElementIndex;
});
return index === -1 ? 0 : index;
@@ -28,6 +28,35 @@
white-space: pre;
}
.IndexInput {
color: var(--color-text);
font-size: var(--font-size-sans-normal);
font-family: inherit;
text-align: center;
background: none;
/* A visible border so it's clear this number can be edited. */
border: 1px solid var(--color-border);
border-radius: 0.125rem;
outline: none;
padding: 0 0.25rem;
margin: 0;
/* Keep a floor so the box doesn't shrink/jitter as the digit count changes. */
min-width: 1.5ch;
}
/* AutoSizeInput sizes the element width to fit the text exactly (assuming no
padding/border). content-box makes our padding + border add around that
width rather than eating into it and clipping digits. The descendant
selector raises specificity above the global `.DevTools *` border-box rule,
which otherwise wins by source order at equal specificity. */
.IndexLabel .IndexInput {
box-sizing: content-box;
}
.IndexInput:focus {
background-color: var(--color-button-background-focus);
}
.LeftVRule{
height: 20px;
width: 1px;
@@ -7,17 +7,24 @@
* @flow
*/
import typeof {
SyntheticEvent,
SyntheticKeyboardEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useEffect, useRef} from 'react';
import {useEffect, useRef, useState} from 'react';
import Button from './Button';
import ButtonIcon from './ButtonIcon';
import Icon from './Icon';
import AutoSizeInput from './Components/NativeStyleEditor/AutoSizeInput';
import styles from './SearchInput.css';
type Props = {
goToNextResult: () => void,
goToPreviousResult: () => void,
goToResult: (index: number) => void,
placeholder: string,
search: (text: string) => void,
searchIndex: number,
@@ -29,6 +36,7 @@ type Props = {
export default function SearchInput({
goToNextResult,
goToPreviousResult,
goToResult,
placeholder,
search,
searchIndex,
@@ -38,6 +46,37 @@ export default function SearchInput({
}: Props): React.Node {
const inputRef = useRef<HTMLInputElement | null>(null);
const [indexDraft, setIndexDraft] = useState<string | null>(null);
const currentResultNumber = Math.min(searchIndex + 1, searchResultsCount);
const indexValue =
indexDraft !== null ? indexDraft : String(currentResultNumber);
const handleIndexChange = (event: SyntheticEvent) => {
// Only digits are meaningful here; strip anything else as it's typed.
const raw = event.currentTarget.value.replace(/[^0-9]/g, '');
if (raw === '' || searchResultsCount === 0) {
setIndexDraft(raw);
return;
}
// Clamp into [1, searchResultsCount] so the field never displays an
// out-of-range value, then live-preview by scrolling to that result.
const clamped = Math.max(
1,
Math.min(parseInt(raw, 10), searchResultsCount),
);
setIndexDraft(String(clamped));
goToResult(clamped - 1);
};
const handleIndexBlur = () => setIndexDraft(null);
const handleIndexKeyDown = (event: SyntheticKeyboardEvent) => {
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault();
event.currentTarget.blur();
}
};
const resetSearch = () => search('');
// $FlowFixMe[missing-local-annot]
@@ -103,7 +142,21 @@ export default function SearchInput({
<span
className={styles.IndexLabel}
data-testname={testName ? `${testName}-ResultsCount` : undefined}>
{Math.min(searchIndex + 1, searchResultsCount)} |{' '}
<AutoSizeInput
className={styles.IndexInput}
testName={testName ? `${testName}-ResultIndexInput` : undefined}
type="text"
inputMode="numeric"
pattern="[0-9]*"
aria-label="Go to search result number"
title="Go to search result number"
disabled={searchResultsCount === 0}
onBlur={handleIndexBlur}
onChange={handleIndexChange}
onKeyDown={handleIndexKeyDown}
value={indexValue}
/>
{' | '}
{searchResultsCount}
</span>
<div className={styles.LeftVRule} />
@@ -0,0 +1,81 @@
/**
* 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 {Fragment} from 'react';
// A large tree of similarly-named components (Table, TableRow, TableCell, ...).
// This mirrors a real virtualized table and is meant for exercising the
// component-tree search box in DevTools:
// 1. Open DevTools and search "Table" — there are 100+ matches.
// 2. Type a number into the result-index field (left of "| N") to jump
// directly to a specific match instead of scrolling/pressing Enter.
// 3. Select a match, clear the search, then retype the same text — the
// search advances to the *next* match instead of snapping back.
const ROWS = 25;
const COLS = 4;
function TableCell({row, col}: {row: number, col: number}): React.Node {
return <td>{`r${row}c${col}`}</td>;
}
function TableColumnHeader({col}: {col: number}): React.Node {
return <th>{`Column ${col}`}</th>;
}
function TableRow({row}: {row: number}): React.Node {
return (
<tr>
{Array.from({length: COLS}, (_, col) => (
<TableCell key={col} row={row} col={col} />
))}
</tr>
);
}
function TableHeaderRow(): React.Node {
return (
<tr>
{Array.from({length: COLS}, (_, col) => (
<TableColumnHeader key={col} col={col} />
))}
</tr>
);
}
function TableBody(): React.Node {
return (
<tbody>
{Array.from({length: ROWS}, (_, row) => (
<TableRow key={row} row={row} />
))}
</tbody>
);
}
function Table(): React.Node {
return (
<table>
<thead>
<TableHeaderRow />
</thead>
<TableBody />
</table>
);
}
export default function SearchableTable(): React.Node {
return (
<Fragment>
<h1>Searchable Table</h1>
<Table />
</Fragment>
);
}
+2
View File
@@ -20,6 +20,7 @@ import ErrorBoundaries from './ErrorBoundaries';
import PartiallyStrictApp from './PartiallyStrictApp';
import Segments from './Segments';
import SuspenseTree from './SuspenseTree';
import SearchableTable from './SearchableTable';
import ActivityTree from './ActivityTree';
import TraceUpdatesTest from './TraceUpdatesTest';
import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
@@ -113,6 +114,7 @@ function mountTestApp() {
mountApp(Toggle);
mountApp(ErrorBoundaries);
mountApp(SuspenseTree);
mountApp(SearchableTable);
mountApp(DeeplyNestedComponents);
mountApp(Iframe);
mountApp(ActivityTree);