[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`.
This commit is contained in:
Anas Khan
2026-07-06 14:18:10 +01:00
committed by GitHub
parent 45a8cf5349
commit 3deee48461
2 changed files with 25 additions and 0 deletions
@@ -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']);
});
});
});
@@ -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);