@samitouri / QOS-React-1 / commits / aa43b0fbd8

[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`.

Anas Khan committed Jul 6, 2026 at 18:48 UTC aa43b0fbd85a11014b17bd6678e439674478859f
2 files changed +25
packages/react-devtools-shared/src/__tests__/utils-test.js
+7
@@ -523,5 +523,12 @@ function f() { }
523 ]);
524 expect(formatConsoleArguments('%s 100%', 'done')).toEqual(['done 100%']);
525 });
526 +
527 + it('keeps specifiers literal when no argument is supplied', () => {
528 + expect(formatConsoleArguments('%s %s', 'the')).toEqual(['the %s']);
529 + expect(formatConsoleArguments('%s %d', 'value')).toEqual(['value %d']);
530 + expect(formatConsoleArguments('%s %i', 'value')).toEqual(['value %i']);
531 + expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']);
532 + });
533 });
534 });
packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js
+18
@@ -45,18 +45,36 @@ export default function formatConsoleArguments(
45 }
46 case 'd':
47 case 'i': {
48 + if (argumentsPointer >= args.length) {
49 + // No argument left for this specifier. Keep it as a literal, like
50 + // the browser console does, rather than emitting 'NaN'.
51 + template += `%${nextChar}`;
52 + break;
53 + }
54 const [arg] = args.splice(argumentsPointer, 1);
55 template += parseInt(arg, 10).toString();
56
57 break;
58 }
59 case 'f': {
60 + if (argumentsPointer >= args.length) {
61 + // No argument left for this specifier. Keep it as a literal, like
62 + // the browser console does, rather than emitting 'NaN'.
63 + template += `%${nextChar}`;
64 + break;
65 + }
66 const [arg] = args.splice(argumentsPointer, 1);
67 template += parseFloat(arg).toString();
68
69 break;
70 }
71 case 's': {
72 + if (argumentsPointer >= args.length) {
73 + // No argument left for this specifier. Keep it as a literal, like
74 + // the browser console does, rather than emitting 'undefined'.
75 + template += `%${nextChar}`;
76 + break;
77 + }
78 const [arg] = args.splice(argumentsPointer, 1);
79 template += String(arg);
80