mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
[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:
@@ -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;
|
||||
|
||||
+6
@@ -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}
|
||||
|
||||
+47
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user