@samitouri / QOS-React-2 / commits / d63cd97245

don't stringify objects for console log second render (#24373)

Fixes #24302 based on #24306. --- The current implementation for strict mode double logging stringiness and dims the second log. However, because we stringify everything, including objects, this causes objects to be logged as `[object Object]` etc. This PR creates a new function that formats console log arguments with a specified style. It does this by: 1. The first param is a string that contains %c: Bail out and return the args without modifying the styles. We don't want to affect styles that the developer deliberately set. 2. The first param is a string that doesn't contain %c but contains string formatting: `[`%c${args[0]}`, style, ...args.slice(1)]` Note: we assume that the string formatting that the developer uses is correct. 3. The first param is a string that doesn't contain string formatting OR is not a string: Create a formatting string where: - boolean, string, symbol -> %s - number -> %f OR %i depending on if it's an int or float - default -> %o --- Co-authored-by: Billy Janitsch <billy@kensho.com>

Luna Ruan committed Apr 14, 2022 at 11:30 UTC d63cd972454125d4572bb8ffbfeccbdf0c5eb27b
5 files changed +223 -71
packages/react-devtools-shared/src/__tests__/console-test.js
+47 -20
@@ -494,20 +494,31 @@ describe('console', () => {
494 );
495 expect(mockLog.mock.calls[0]).toHaveLength(1);
496 expect(mockLog.mock.calls[0][0]).toBe('log');
497 - expect(mockLog.mock.calls[1]).toHaveLength(2);
498 - expect(mockLog.mock.calls[1][0]).toBe('%clog');
497 + expect(mockLog.mock.calls[1]).toEqual([
498 + '%c%s',
499 + `color: ${process.env.DARK_MODE_DIMMED_LOG_COLOR}`,
500 + 'log',
501 + ]);
502
503 expect(mockWarn).toHaveBeenCalledTimes(2);
504 expect(mockWarn.mock.calls[0]).toHaveLength(1);
505 expect(mockWarn.mock.calls[0][0]).toBe('warn');
503 - expect(mockWarn.mock.calls[1]).toHaveLength(2);
504 - expect(mockWarn.mock.calls[1][0]).toBe('%cwarn');
506 + expect(mockWarn.mock.calls[1]).toHaveLength(3);
507 + expect(mockWarn.mock.calls[1]).toEqual([
508 + '%c%s',
509 + `color: ${process.env.DARK_MODE_DIMMED_WARNING_COLOR}`,
510 + 'warn',
511 + ]);
512
513 expect(mockError).toHaveBeenCalledTimes(2);
514 expect(mockError.mock.calls[0]).toHaveLength(1);
515 expect(mockError.mock.calls[0][0]).toBe('error');
509 - expect(mockError.mock.calls[1]).toHaveLength(2);
510 - expect(mockError.mock.calls[1][0]).toBe('%cerror');
516 + expect(mockError.mock.calls[1]).toHaveLength(3);
517 + expect(mockError.mock.calls[1]).toEqual([
518 + '%c%s',
519 + `color: ${process.env.DARK_MODE_DIMMED_ERROR_COLOR}`,
520 + 'error',
521 + ]);
522 });
523
524 it('should not double log if hideConsoleLogsInStrictMode is enabled in Strict mode', () => {
@@ -577,20 +588,32 @@ describe('console', () => {
588 expect(mockLog).toHaveBeenCalledTimes(2);
589 expect(mockLog.mock.calls[0]).toHaveLength(1);
590 expect(mockLog.mock.calls[0][0]).toBe('log');
580 - expect(mockLog.mock.calls[1]).toHaveLength(2);
581 - expect(mockLog.mock.calls[1][0]).toBe('%clog');
591 + expect(mockLog.mock.calls[1]).toHaveLength(3);
592 + expect(mockLog.mock.calls[1]).toEqual([
593 + '%c%s',
594 + `color: ${process.env.DARK_MODE_DIMMED_LOG_COLOR}`,
595 + 'log',
596 + ]);
597
598 expect(mockWarn).toHaveBeenCalledTimes(2);
599 expect(mockWarn.mock.calls[0]).toHaveLength(1);
600 expect(mockWarn.mock.calls[0][0]).toBe('warn');
586 - expect(mockWarn.mock.calls[1]).toHaveLength(2);
587 - expect(mockWarn.mock.calls[1][0]).toBe('%cwarn');
601 + expect(mockWarn.mock.calls[1]).toHaveLength(3);
602 + expect(mockWarn.mock.calls[1]).toEqual([
603 + '%c%s',
604 + `color: ${process.env.DARK_MODE_DIMMED_WARNING_COLOR}`,
605 + 'warn',
606 + ]);
607
608 expect(mockError).toHaveBeenCalledTimes(2);
609 expect(mockError.mock.calls[0]).toHaveLength(1);
610 expect(mockError.mock.calls[0][0]).toBe('error');
592 - expect(mockError.mock.calls[1]).toHaveLength(2);
593 - expect(mockError.mock.calls[1][0]).toBe('%cerror');
611 + expect(mockError.mock.calls[1]).toHaveLength(3);
612 + expect(mockError.mock.calls[1]).toEqual([
613 + '%c%s',
614 + `color: ${process.env.DARK_MODE_DIMMED_ERROR_COLOR}`,
615 + 'error',
616 + ]);
617 });
618
619 it('should not double log in Strict mode initial render for extension', () => {
@@ -666,22 +689,26 @@ describe('console', () => {
689 expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual(
690 '\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
691 );
669 - expect(mockWarn.mock.calls[1]).toHaveLength(2);
670 - expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][0])).toEqual(
671 - '%cwarn \n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
672 - );
692 + expect(mockWarn.mock.calls[1]).toHaveLength(4);
693 + expect(mockWarn.mock.calls[1][0]).toEqual('%c%s %s');
694 expect(mockWarn.mock.calls[1][1]).toMatch('color: rgba(');
695 + expect(mockWarn.mock.calls[1][2]).toEqual('warn');
696 + expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][3]).trim()).toEqual(
697 + 'in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
698 + );
699
700 expect(mockError).toHaveBeenCalledTimes(2);
701 expect(mockError.mock.calls[0]).toHaveLength(2);
702 expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toEqual(
703 '\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
704 );
680 - expect(mockError.mock.calls[1]).toHaveLength(2);
681 - expect(normalizeCodeLocInfo(mockError.mock.calls[1][0])).toEqual(
682 - '%cerror \n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
683 - );
705 + expect(mockError.mock.calls[1]).toHaveLength(4);
706 + expect(mockError.mock.calls[1][0]).toEqual('%c%s %s');
707 expect(mockError.mock.calls[1][1]).toMatch('color: rgba(');
708 + expect(mockError.mock.calls[1][2]).toEqual('error');
709 + expect(normalizeCodeLocInfo(mockError.mock.calls[1][3]).trim()).toEqual(
710 + 'in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
711 + );
712 });
713 });
714
packages/react-devtools-shared/src/__tests__/utils-test.js
+81 -1
@@ -11,7 +11,10 @@ import {
11 getDisplayName,
12 getDisplayNameForReactElement,
13 } from 'react-devtools-shared/src/utils';
14 -import {format} from 'react-devtools-shared/src/backend/utils';
14 +import {
15 + format,
16 + formatWithStyles,
17 +} from 'react-devtools-shared/src/backend/utils';
18 import {
19 REACT_SUSPENSE_LIST_TYPE as SuspenseList,
20 REACT_STRICT_MODE_TYPE as StrictMode,
@@ -110,4 +113,81 @@ describe('utils', () => {
113 expect(format(Symbol('abc'), 123)).toEqual('Symbol(abc) 123');
114 });
115 });
116 +
117 + describe('formatWithStyles', () => {
118 + it('should format empty arrays', () => {
119 + expect(formatWithStyles([])).toEqual([]);
120 + expect(formatWithStyles([], 'gray')).toEqual([]);
121 + expect(formatWithStyles(undefined)).toEqual(undefined);
122 + });
123 +
124 + it('should bail out of strings with styles', () => {
125 + expect(
126 + formatWithStyles(['%ca', 'color: green', 'b', 'c'], 'color: gray'),
127 + ).toEqual(['%ca', 'color: green', 'b', 'c']);
128 + });
129 +
130 + it('should format simple strings', () => {
131 + expect(formatWithStyles(['a'])).toEqual(['a']);
132 +
133 + expect(formatWithStyles(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
134 + expect(formatWithStyles(['a'], 'color: gray')).toEqual([
135 + '%c%s',
136 + 'color: gray',
137 + 'a',
138 + ]);
139 + expect(formatWithStyles(['a', 'b', 'c'], 'color: gray')).toEqual([
140 + '%c%s %s %s',
141 + 'color: gray',
142 + 'a',
143 + 'b',
144 + 'c',
145 + ]);
146 + });
147 +
148 + it('should format string substituions', () => {
149 + expect(
150 + formatWithStyles(['%s %s %s', 'a', 'b', 'c'], 'color: gray'),
151 + ).toEqual(['%c%s %s %s', 'color: gray', 'a', 'b', 'c']);
152 +
153 + // The last letter isn't gray here but I think it's not a big
154 + // deal, since there is a string substituion but it's incorrect
155 + expect(
156 + formatWithStyles(['%s %s', 'a', 'b', 'c'], 'color: gray'),
157 + ).toEqual(['%c%s %s', 'color: gray', 'a', 'b', 'c']);
158 + });
159 +
160 + it('should support multiple argument types', () => {
161 + const symbol = Symbol('a');
162 + expect(
163 + formatWithStyles(
164 + ['abc', 123, 12.3, true, {hello: 'world'}, symbol],
165 + 'color: gray',
166 + ),
167 + ).toEqual([
168 + '%c%s %i %f %s %o %s',
169 + 'color: gray',
170 + 'abc',
171 + 123,
172 + 12.3,
173 + true,
174 + {hello: 'world'},
175 + symbol,
176 + ]);
177 + });
178 +
179 + it('should properly format escaped string substituions', () => {
180 + expect(formatWithStyles(['%%s'], 'color: gray')).toEqual([
181 + '%c%s',
182 + 'color: gray',
183 + '%%s',
184 + ]);
185 + expect(formatWithStyles(['%%c'], 'color: gray')).toEqual([
186 + '%c%s',
187 + 'color: gray',
188 + '%%c',
189 + ]);
190 + expect(formatWithStyles(['%%c%c'], 'color: gray')).toEqual(['%%c%c']);
191 + });
192 + });
193 });
packages/react-devtools-shared/src/backend/console.js
+5 -4
@@ -10,7 +10,7 @@
10 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
11 import type {CurrentDispatcherRef, ReactRenderer, WorkTagMap} from './types';
12 import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools';
13 -import {format} from './utils';
13 +import {format, formatWithStyles} from './utils';
14
15 import {getInternalReactConstants} from './renderer';
16 import {getStackByFiberInDevAndProd} from './DevToolsFiberComponentStack';
@@ -38,7 +38,7 @@ const STYLE_DIRECTIVE_REGEX = /^%c/;
38 // so the console color stays consistent
39 function isStrictModeOverride(args: Array<string>, method: string): boolean {
40 return (
41 - args.length === 2 &&
41 + args.length >= 2 &&
42 STYLE_DIRECTIVE_REGEX.test(args[0]) &&
43 args[1] === `color: ${getConsoleColor(method) || ''}`
44 );
@@ -246,7 +246,8 @@ export function patch({
246 );
247 if (componentStack !== '') {
248 if (isStrictModeOverride(args, method)) {
249 - args[0] = format(args[0], componentStack);
249 + args[0] = `${args[0]} %s`;
250 + args.push(componentStack);
251 } else {
252 args.push(componentStack);
253 }
@@ -335,7 +336,7 @@ export function patchForStrictMode() {
336 } else {
337 const color = getConsoleColor(method);
338 if (color) {
338 - originalMethod(`%c${format(...args)}`, `color: ${color}`);
339 + originalMethod(...formatWithStyles(args, `color: ${color}`));
340 } else {
341 throw Error('Console color is not defined');
342 }
packages/react-devtools-shared/src/backend/utils.js
+56
@@ -156,6 +156,62 @@ export function serializeToString(data: any): string {
156 });
157 }
158
159 +// Formats an array of args with a style for console methods, using
160 +// the following algorithm:
161 +// 1. The first param is a string that contains %c
162 +// - Bail out and return the args without modifying the styles.
163 +// We don't want to affect styles that the developer deliberately set.
164 +// 2. The first param is a string that doesn't contain %c but contains
165 +// string formatting
166 +// - [`%c${args[0]}`, style, ...args.slice(1)]
167 +// - Note: we assume that the string formatting that the developer uses
168 +// is correct.
169 +// 3. The first param is a string that doesn't contain string formatting
170 +// OR is not a string
171 +// - Create a formatting string where:
172 +// boolean, string, symbol -> %s
173 +// number -> %f OR %i depending on if it's an int or float
174 +// default -> %o
175 +export function formatWithStyles(
176 + inputArgs: $ReadOnlyArray<any>,
177 + style?: string,
178 +): $ReadOnlyArray<any> {
179 + if (
180 + inputArgs === undefined ||
181 + inputArgs === null ||
182 + inputArgs.length === 0 ||
183 + // Matches any of %c but not %%c
184 + (typeof inputArgs[0] === 'string' && inputArgs[0].match(/([^%]|^)(%c)/g)) ||
185 + style === undefined
186 + ) {
187 + return inputArgs;
188 + }
189 +
190 + // Matches any of %(o|O|i|s|f), but not %%(o|O|i|s|f)
191 + const REGEXP = /([^%]|^)(%([oOdisf]))/g;
192 + if (inputArgs[0].match(REGEXP)) {
193 + return [`%c${inputArgs[0]}`, style, ...inputArgs.slice(1)];
194 + } else {
195 + const firstArg = inputArgs.reduce((formatStr, elem, i) => {
196 + if (i > 0) {
197 + formatStr += ' ';
198 + }
199 + switch (typeof elem) {
200 + case 'string':
201 + case 'boolean':
202 + case 'symbol':
203 + return (formatStr += '%s');
204 + case 'number':
205 + const formatting = Number.isInteger(elem) ? '%i' : '%f';
206 + return (formatStr += formatting);
207 + default:
208 + return (formatStr += '%o');
209 + }
210 + }, '%c');
211 + return [firstArg, style, ...inputArgs];
212 + }
213 +}
214 +
215 // based on https://github.com/tmpfs/format-util/blob/0e62d430efb0a1c51448709abd3e2406c14d8401/format.js#L1
216 // based on https://developer.mozilla.org/en-US/docs/Web/API/console#Using_string_substitutions
217 // Implements s, d, i and f placeholders
packages/react-devtools-shared/src/hook.js
+34 -46
@@ -172,54 +172,42 @@ export function installHook(target: any): DevToolsHook | null {
172 }
173
174 // NOTE: KEEP IN SYNC with src/backend/utils.js
175 - function format(
176 - maybeMessage: any,
177 - ...inputArgs: $ReadOnlyArray<any>
178 - ): string {
179 - const args = inputArgs.slice();
180 -
181 - // Symbols cannot be concatenated with Strings.
182 - let formatted = String(maybeMessage);
183 -
184 - // If the first argument is a string, check for substitutions.
185 - if (typeof maybeMessage === 'string') {
186 - if (args.length) {
187 - const REGEXP = /(%?)(%([jds]))/g;
188 -
189 - formatted = formatted.replace(REGEXP, (match, escaped, ptn, flag) => {
190 - let arg = args.shift();
191 - switch (flag) {
192 - case 's':
193 - arg += '';
194 - break;
195 - case 'd':
196 - case 'i':
197 - arg = parseInt(arg, 10).toString();
198 - break;
199 - case 'f':
200 - arg = parseFloat(arg).toString();
201 - break;
202 - }
203 - if (!escaped) {
204 - return arg;
205 - }
206 - args.unshift(arg);
207 - return match;
208 - });
209 - }
175 + function formatWithStyles(
176 + inputArgs: $ReadOnlyArray<any>,
177 + style?: string,
178 + ): $ReadOnlyArray<any> {
179 + if (
180 + inputArgs === undefined ||
181 + inputArgs === null ||
182 + inputArgs.length === 0 ||
183 + (typeof inputArgs[0] === 'string' && inputArgs[0].includes('%c')) ||
184 + style === undefined
185 + ) {
186 + return inputArgs;
187 }
188
212 - // Arguments that remain after formatting.
213 - if (args.length) {
214 - for (let i = 0; i < args.length; i++) {
215 - formatted += ' ' + String(args[i]);
216 - }
189 + const REGEXP = /(%?)(%([oOdisf]))/g;
190 + if (inputArgs[0].match(REGEXP)) {
191 + return [`%c${inputArgs[0]}`, style, ...inputArgs.slice(1)];
192 + } else {
193 + const firstArg = inputArgs.reduce((formatStr, elem, i) => {
194 + if (i > 0) {
195 + formatStr += ' ';
196 + }
197 + switch (typeof elem) {
198 + case 'string':
199 + case 'boolean':
200 + case 'symbol':
201 + return (formatStr += '%s');
202 + case 'number':
203 + const formatting = Number.isInteger(elem) ? '%i' : '%f';
204 + return (formatStr += formatting);
205 + default:
206 + return (formatStr += '%o');
207 + }
208 + }, '%c');
209 + return [firstArg, style, ...inputArgs];
210 }
218 -
219 - // Update escaped %% values.
220 - formatted = formatted.replace(/%{2,2}/g, '%');
221 -
222 - return String(formatted);
211 }
212
213 let unpatchFn = null;
@@ -291,7 +279,7 @@ export function installHook(target: any): DevToolsHook | null {
279 }
280
281 if (color) {
294 - originalMethod(`%c${format(...args)}`, `color: ${color}`);
282 + originalMethod(...formatWithStyles(args, `color: ${color}`));
283 } else {
284 throw Error('Console color is not defined');
285 }