From 00bacdeb1fccbd492198c83a62f8620104607629 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:46:15 +0530 Subject: [PATCH 1/6] [react-devtools] substitute %i and %f in console format strings (#36929) `formatConsoleArgumentsToSingleString` in `packages/react-devtools-shared/src/backend/utils/index.js` inlines `console.*` printf-style substitutions into a single string. That string is used both as the dedup key and as the displayed text for per-component warnings/errors. The `switch` that consumes the captured flag handles `s`, `d`, `i`, and `f`, and the function's own header comment says it "Implements s, d, i and f placeholders". But the substitution regex only captured `[jds]`: ```js const REGEXP = /(%?)(%([jds]))/g; ``` So `%i` and `%f` were never matched. The `case 'i'` and `case 'f'` arms were dead code: the specifier was emitted literally and its argument was never consumed. Worse, because the unmatched specifier does not shift its argument, every following specifier in the same format string then binds to the wrong argument (a cascading off-by-one over the remaining args). `%i` and `%f` are standard console integer/float specifiers (Node `util.format` and browsers both support them), so this affected common log formats. The fix adds `i` and `f` to the regex class so the existing switch arms run: ```js const REGEXP = /(%?)(%([jdisf]))/g; ``` This is a one-character-class change that reconciles the regex with the switch and the header comment. The pre-existing behavior that `%j` is matched but has no `case` (so it falls through unchanged) is intentionally left as-is; it is out of scope for this fix. ## How did you test this change? Added three regression tests to the existing `formatConsoleArgumentsToSingleString` describe block in `packages/react-devtools-shared/src/__tests__/utils-test.js`: - `formatConsoleArgumentsToSingleString('%i', 3.14)` -> `'3'` - `formatConsoleArgumentsToSingleString('%f', 3.5)` -> `'3.5'` - `formatConsoleArgumentsToSingleString('a %i b %s', 7, 'x')` -> `'a 7 b x'` (locks in argument alignment) Commands run locally (experimental devtools bundles): ``` yarn build-for-devtools yarn test --build --project=devtools -r=experimental packages/react-devtools-shared/src/__tests__/utils-test.js ``` Result: `Tests: 53 passed, 53 total`. To confirm the tests actually cover the bug, I reverted the one-character fix back to `[jds]` and reran: the three new tests fail exactly as the bug predicts, e.g. `%i` yields `"%i 3.14"` and `a %i b %s` yields `"a %i b 7 x"` (the `%s` binds to `7` instead of `x`, showing the off-by-one). Restoring the fix makes them pass again. Also green: ``` yarn linc # ESLint on changed files: passed yarn prettier # no files reflagged yarn flow dom-node # No errors! ``` --- .../src/__tests__/utils-test.js | 14 ++++++++++++++ .../src/backend/utils/index.js | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 3d42aa28741c..45188c107d00 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -147,6 +147,20 @@ describe('utils', () => { ).toEqual('a 123 b true c'); }); + it('should support integer substitutions', () => { + expect(formatConsoleArgumentsToSingleString('%i', 3.14)).toEqual('3'); + }); + + it('should support float substitutions', () => { + expect(formatConsoleArgumentsToSingleString('%f', 3.5)).toEqual('3.5'); + }); + + it('should keep argument alignment across mixed substitutions', () => { + expect(formatConsoleArgumentsToSingleString('a %i b %s', 7, 'x')).toEqual( + 'a 7 b x', + ); + }); + it('should gracefully handle Symbol types', () => { expect( formatConsoleArgumentsToSingleString(Symbol('a'), 'b', Symbol('c')), diff --git a/packages/react-devtools-shared/src/backend/utils/index.js b/packages/react-devtools-shared/src/backend/utils/index.js index 816145fdb19d..198364ec304c 100644 --- a/packages/react-devtools-shared/src/backend/utils/index.js +++ b/packages/react-devtools-shared/src/backend/utils/index.js @@ -193,7 +193,7 @@ export function formatConsoleArgumentsToSingleString( // If the first argument is a string, check for substitutions. if (typeof maybeMessage === 'string') { if (args.length) { - const REGEXP = /(%?)(%([jds]))/g; + const REGEXP = /(%?)(%([jdisf]))/g; // $FlowFixMe[incompatible-type] formatted = formatted.replace(REGEXP, (match, escaped, ptn, flag) => { From aa43b0fbd85a11014b17bd6678e439674478859f Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:48:10 +0530 Subject: [PATCH 2/6] [react-devtools] Keep console specifiers literal when no argument is supplied (#36930) `formatConsoleArguments` in `packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js` is used by the DevTools backend (via `hook.js`) to inline `console.*` printf-style substitutions after stripping React's appended component stack. For `%s`/`%d`/`%i`/`%f` it consumes the next argument with `args.splice(argumentsPointer, 1)` and formats the result. When a format string has more specifiers than arguments, `splice` returns an empty array, so `arg` is `undefined` and the specifier is rendered as text: `%s` becomes `"undefined"`, and `%d`/`%i`/`%f` become `"NaN"`. ```js formatConsoleArguments('%s %s', 'the'); // before: ['the undefined'] // after: ['the %s'] ``` Browsers and Node's `util.format` leave an unmatched specifier as a literal (`console.log('%s %s', 'a')` prints `a %s`; `console.log('%d')` prints `%d`). So a message like `console.warn('value: %s')` was shown in DevTools as `value: undefined` instead of `value: %s`. This guards each of the `%d`/`%i`, `%f`, and `%s` cases on argument availability (`argumentsPointer >= args.length`): when nothing is left to consume it keeps the specifier text and does not splice, mirroring the existing trailing-`%` handling added in #36852. An explicitly passed `undefined`/`null` argument is unchanged and still renders as `undefined`/`null`, since a value is present at that position (the `formats nullish values` test still passes). ## How did you test this change? Added a regression test to the existing `formatConsoleArguments` describe block in `packages/react-devtools-shared/src/__tests__/utils-test.js`: ```js it('keeps specifiers literal when no argument is supplied', () => { expect(formatConsoleArguments('%s %s', 'the')).toEqual(['the %s']); expect(formatConsoleArguments('%s %d', 'value')).toEqual(['value %d']); expect(formatConsoleArguments('%s %i', 'value')).toEqual(['value %i']); expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']); }); ``` Each assertion fails on `main` (it produces `['the undefined']` and `['value NaN']`) and passes with the fix. Commands run locally: ``` yarn test --build --project devtools packages/react-devtools-shared/src/__tests__/utils-test.js # 51 passed, 51 total yarn lint packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js \ packages/react-devtools-shared/src/__tests__/utils-test.js # Lint passed. yarn flow dom-node # No errors! yarn prettier-check packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js \ packages/react-devtools-shared/src/__tests__/utils-test.js # clean ``` Cross-checked the expected output against Node `util.format`: `util.format('%s %s', 'the')` -> `the %s`; `util.format('%d')` -> `%d`. --- .../src/__tests__/utils-test.js | 7 +++++++ .../backend/utils/formatConsoleArguments.js | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 45188c107d00..1d8dfbc05e50 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -523,5 +523,12 @@ function f() { } ]); expect(formatConsoleArguments('%s 100%', 'done')).toEqual(['done 100%']); }); + + it('keeps specifiers literal when no argument is supplied', () => { + expect(formatConsoleArguments('%s %s', 'the')).toEqual(['the %s']); + expect(formatConsoleArguments('%s %d', 'value')).toEqual(['value %d']); + expect(formatConsoleArguments('%s %i', 'value')).toEqual(['value %i']); + expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']); + }); }); }); diff --git a/packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js b/packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js index 551f4cf674b8..8b699f5e9b37 100644 --- a/packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js +++ b/packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js @@ -45,18 +45,36 @@ export default function formatConsoleArguments( } case 'd': case 'i': { + if (argumentsPointer >= args.length) { + // No argument left for this specifier. Keep it as a literal, like + // the browser console does, rather than emitting 'NaN'. + template += `%${nextChar}`; + break; + } const [arg] = args.splice(argumentsPointer, 1); template += parseInt(arg, 10).toString(); break; } case 'f': { + if (argumentsPointer >= args.length) { + // No argument left for this specifier. Keep it as a literal, like + // the browser console does, rather than emitting 'NaN'. + template += `%${nextChar}`; + break; + } const [arg] = args.splice(argumentsPointer, 1); template += parseFloat(arg).toString(); break; } case 's': { + if (argumentsPointer >= args.length) { + // No argument left for this specifier. Keep it as a literal, like + // the browser console does, rather than emitting 'undefined'. + template += `%${nextChar}`; + break; + } const [arg] = args.splice(argumentsPointer, 1); template += String(arg); From 4f9389423b7319e1f7acc3d158c84a8365462748 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:48:44 +0530 Subject: [PATCH 3/6] docs: remove stale parentType param from validateChildKeys JSDoc (#36928) `validateChildKeys` in `packages/react/src/jsx/ReactJSXElement.js` had a signature of `validateChildKeys(node, parentType)`, but #34174 ("Remove unused arguments from ReactElement") dropped the second argument, changing the signature to `validateChildKeys(node)` and updating every call site to pass a single argument. That change removed several now-unused `@param` lines from the same file, but left one behind on `validateChildKeys`: ```js /** * ... * @internal * @param {ReactNode} node Statically passed child of any type. * @param {*} parentType node's parent's type. // <- no such parameter anymore */ function validateChildKeys(node) { ``` `parentType` no longer appears anywhere in the function signature or body, so this `@param` line is stale and misleading to anyone reading the doc comment. This PR deletes that single line. The remaining `@param {ReactNode} node` already fully and correctly documents the sole parameter. No code or behavior change. ## How did you test this change? This is a documentation-only change (a JSDoc comment on an `@internal` helper), so there is no runtime behavior to test. I verified it as follows: - Confirmed `parentType` no longer appears anywhere in `packages/react/src/jsx/ReactJSXElement.js` (`grep -n parentType` returns no matches after the change). - Confirmed the signature `function validateChildKeys(node)` and all call sites are unchanged by this diff. - `yarn prettier` (via `scripts/prettier/index.js check-changed`) - clean. - `yarn linc` (ESLint on changed files) - passed. - `yarn flow dom-node` - No errors. --- ## Diff (for reference) ```diff diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js @@ -860,7 +860,6 @@ export function cloneElement(element, config, children) { * * @internal * @param {ReactNode} node Statically passed child of any type. - * @param {*} parentType node's parent's type. */ function validateChildKeys(node) { ``` --- packages/react/src/jsx/ReactJSXElement.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index 3a5a5d51a9b8..0d1b46f75722 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -860,7 +860,6 @@ export function cloneElement(element, config, children) { * * @internal * @param {ReactNode} node Statically passed child of any type. - * @param {*} parentType node's parent's type. */ function validateChildKeys(node) { if (__DEV__) { From 4b5d1c937e365ae4d4c27abd5e26f7467acb4fb0 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:57:17 +0530 Subject: [PATCH 4/6] [DevTools] Fix printOperationsArray decode of applied activity slice change (#36935) `printOperationsArray` in `react-devtools-shared` walks an operations array under the invariant that each `switch` case leaves `i` pointing at the next opcode (loop header at `packages/react-devtools-shared/src/utils.js`). The `TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE` case broke that invariant: ```js case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: { i++; // skip opcode -> i now at the value slot const activitySliceIDChange = operations[i + 1]; // reads the slot AFTER the value; i not advanced ... } ``` The operation is exactly two slots, `[opcode, activitySliceID]` (see the writer in `packages/react-devtools-shared/src/backend/fiber/renderer.js`, which pushes the opcode then the id). So the case did two things wrong: 1. It logged the wrong number: `operations[i + 1]` reads the slot *after* the value (the next operation's opcode, or `undefined` at the end of the array). 2. It left `i` pointing at the value slot, so the outer `while (i < operations.length)` loop re-read the activity-slice id as an opcode. For any non-zero slice id that falls through to `default: throw Error("Unsupported Bridge operation ...")`, aborting the whole dump. The two canonical decoders of this same operation both use the correct pattern (skip the opcode, then read *and* advance past the value): - `devtools/store.js`: `i++; nextActivitySliceID = operations[i++];` - `devtools/views/Profiler/CommitTreeBuilder.js`: `i++; const activitySliceIDChange = operations[i++];` This change makes `printOperationsArray` match them by reading `operations[i++]`. This is a debug-only diagnostic path: the only caller is the `__DEBUG__`-guarded dump in `backend/legacy/renderer.js`, so it is not a production crash. The bug was introduced in #34908. ## How did you test this change? Added a regression test for `printOperationsArray` in `packages/react-devtools-shared/src/__tests__/utils-test.js`. The fixture chains two activity-slice operations, `[rendererID, rootID, stringTableSize=0, opcode, 42, opcode, 0]`; the trailing operation is what forces the reader to advance past the first value slot rather than re-read it. It asserts the call does not throw, logs once, and that the message contains both `Applied activity slice change to 42` and `Reset applied activity slice`. Ran the DevTools Jest project (built first, as that project requires a build): - With the fix: 51/51 pass, including the new test. - Reverting only the one-line fix back to `operations[i + 1]` and rebuilding: the new test fails with `Unsupported Bridge operation "42"` (exactly the predicted failure), 50 pass / 1 fail. Restored the fix and it is green again. `yarn prettier-check` and `yarn linc` are clean on the changed files. --- .../src/__tests__/utils-test.js | 38 +++++++++++++++++++ packages/react-devtools-shared/src/utils.js | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 1d8dfbc05e50..03946618766f 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -11,7 +11,9 @@ import { getDisplayName, getDisplayNameForReactElement, isPlainObject, + printOperationsArray, } from 'react-devtools-shared/src/utils'; +import {TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE} from 'react-devtools-shared/src/constants'; import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils'; import { formatConsoleArguments, @@ -531,4 +533,40 @@ function f() { } expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']); }); }); + + describe('printOperationsArray', () => { + let log; + beforeEach(() => { + log = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + log.mockRestore(); + }); + + // The operation is [opcode, activitySliceID] (2 slots). A trailing operation + // after it verifies that the reader advances past the value slot instead of + // re-reading it as the next opcode. + it('should log an applied activity slice change and advance past its value', () => { + const rendererID = 1; + const rootID = 1; + const stringTableSize = 0; + const operations = [ + rendererID, + rootID, + stringTableSize, + TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, + 42, + TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, + 0, + ]; + + expect(() => printOperationsArray(operations)).not.toThrow(); + + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain( + 'Applied activity slice change to 42', + ); + expect(log.mock.calls[0][0]).toContain('Reset applied activity slice'); + }); + }); }); diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 22e4c444ddfa..65609ab25321 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -440,7 +440,7 @@ export function printOperationsArray(operations: Array) { } case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: { i++; - const activitySliceIDChange = operations[i + 1]; + const activitySliceIDChange = operations[i++]; logs.push( activitySliceIDChange === 0 ? 'Reset applied activity slice' From 5afc23a2b6784d2c085a67f2a3cf9d84e7e6c301 Mon Sep 17 00:00:00 2001 From: BIKI DAS <72331432+Biki-das@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:29:51 +0530 Subject: [PATCH 5/6] [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. --- .../__tests__/__e2e__/components.test.js | 16 +- .../src/__tests__/treeContext-test.js | 166 ++++++++++++++++++ .../views/Components/ComponentSearchInput.js | 6 + .../devtools/views/Components/TreeContext.js | 53 +++++- .../src/devtools/views/SearchInput.css | 29 +++ .../src/devtools/views/SearchInput.js | 57 +++++- .../src/app/SearchableTable/index.js | 81 +++++++++ .../react-devtools-shell/src/app/index.js | 2 + 8 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 packages/react-devtools-shell/src/app/SearchableTable/index.js diff --git a/packages/react-devtools-inline/__tests__/__e2e__/components.test.js b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js index 3d187c3ddaba..9c4dd6af5bac 100644 --- a/packages/react-devtools-inline/__tests__/__e2e__/components.test.js +++ b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js @@ -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); } diff --git a/packages/react-devtools-shared/src/__tests__/treeContext-test.js b/packages/react-devtools-shared/src/__tests__/treeContext-test.js index c48aec8f10ac..5c5f6dff924c 100644 --- a/packages/react-devtools-shared/src/__tests__/treeContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/treeContext-test.js @@ -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( + + + + + + , + ), + ); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + + // search for "ba" (matches both elements and ) + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); + + // jump directly to the third result + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 2})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + + → + `); + + // jump directly back to the first result + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); + + // out-of-range indices are clamped to the valid range + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 99})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + + → + `); + + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: -5})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); + }); + + 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( + + + + + + , + ), + ); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'nomatch'})); + utils.act(() => renderer.update()); + expect(state.searchResults).toHaveLength(0); + expect(state.searchIndex).toBe(null); + + utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0})); + utils.act(() => renderer.update()); + expect(state.searchIndex).toBe(null); + expect(state.inspectedElementID).toBe(null); + expect(state).toMatchInlineSnapshot(` + [root] + + + + + `); + }); + + it('should advance past the selected result when retyping the same search', () => { + const Foo = () => null; + const Bar = () => null; + const Baz = () => null; + + utils.act(() => + render( + + + + + + , + ), + ); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + + // search for "ba" and step to the second result () + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); + utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + → + + `); + + // clear the search; the matched element stays selected + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + → + + `); + + // retype the same query: instead of snapping back to the still-selected + // , the search advances to the next match (find-next semantics) + utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + + → + `); + }); + it('should add newly mounted elements to the search results set if they match the current text', async () => { const Foo = () => null; const Bar = () => null; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js index 654fe7691837..81506b673d7c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/ComponentSearchInput.js @@ -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 ( , Map, 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, 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; diff --git a/packages/react-devtools-shared/src/devtools/views/SearchInput.css b/packages/react-devtools-shared/src/devtools/views/SearchInput.css index 960bb152cb22..a61855eea09a 100644 --- a/packages/react-devtools-shared/src/devtools/views/SearchInput.css +++ b/packages/react-devtools-shared/src/devtools/views/SearchInput.css @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/views/SearchInput.js b/packages/react-devtools-shared/src/devtools/views/SearchInput.js index 3d94128bd67b..745d9cdc51bf 100644 --- a/packages/react-devtools-shared/src/devtools/views/SearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/SearchInput.js @@ -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(null); + const [indexDraft, setIndexDraft] = useState(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({ - {Math.min(searchIndex + 1, searchResultsCount)} |{' '} + + {' | '} {searchResultsCount}
diff --git a/packages/react-devtools-shell/src/app/SearchableTable/index.js b/packages/react-devtools-shell/src/app/SearchableTable/index.js new file mode 100644 index 000000000000..f9069c6dfe88 --- /dev/null +++ b/packages/react-devtools-shell/src/app/SearchableTable/index.js @@ -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 {`r${row}c${col}`}; +} + +function TableColumnHeader({col}: {col: number}): React.Node { + return {`Column ${col}`}; +} + +function TableRow({row}: {row: number}): React.Node { + return ( + + {Array.from({length: COLS}, (_, col) => ( + + ))} + + ); +} + +function TableHeaderRow(): React.Node { + return ( + + {Array.from({length: COLS}, (_, col) => ( + + ))} + + ); +} + +function TableBody(): React.Node { + return ( + + {Array.from({length: ROWS}, (_, row) => ( + + ))} + + ); +} + +function Table(): React.Node { + return ( + + + + + +
+ ); +} + +export default function SearchableTable(): React.Node { + return ( + +

Searchable Table

+ + + ); +} diff --git a/packages/react-devtools-shell/src/app/index.js b/packages/react-devtools-shell/src/app/index.js index 0207a8934c2f..16c140707057 100644 --- a/packages/react-devtools-shell/src/app/index.js +++ b/packages/react-devtools-shell/src/app/index.js @@ -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); From 23def8fd0bd46f8ee56f81190b2bdebe37fd573a Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:23:25 +0400 Subject: [PATCH 6/6] fix[flow]: apply new type cast syntax (#36938) Quick follow-up to https://github.com/react/react/pull/36786, which wasn't rebased onto version of `main` that already had Flow upgraded. --- .../src/devtools/views/Components/TreeContext.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index e7005ff9048b..62bef6b0cb31 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -536,7 +536,7 @@ function reduceSearchState(store: Store, state: State, action: Action): State { // 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; + const targetIndex = (action as ACTION_GO_TO_SEARCH_RESULT).payload; searchIndex = Math.max( 0, Math.min(targetIndex, numPrevSearchResults - 1),