Files
Anas KhanandGitHub 3deee48461 [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`.
2026-07-06 14:18:10 +01:00

97 lines
2.7 KiB
JavaScript

/**
* 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
*/
// Do not add / import anything to this file.
// This function could be used from multiple places, including hook.
// Skips CSS and object arguments, inlines other in the first argument as a template string
export default function formatConsoleArguments(
maybeMessage: any,
...inputArgs: $ReadOnlyArray<any>
): $ReadOnlyArray<any> {
if (inputArgs.length === 0 || typeof maybeMessage !== 'string') {
return [maybeMessage, ...inputArgs];
}
const args = inputArgs.slice();
let template = '';
let argumentsPointer = 0;
for (let i = 0; i < maybeMessage.length; ++i) {
const currentChar = maybeMessage[i];
if (currentChar !== '%') {
template += currentChar;
continue;
}
const nextChar = maybeMessage[i + 1];
++i;
// Only keep CSS and objects, inline other arguments
switch (nextChar) {
case 'c':
case 'O':
case 'o': {
++argumentsPointer;
template += `%${nextChar}`;
break;
}
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);
break;
}
default:
if (nextChar === undefined) {
// A trailing '%' with no following character. Keep it as a literal
// '%' rather than emitting the string 'undefined'.
template += '%';
} else {
template += `%${nextChar}`;
}
}
}
return [template, ...args];
}