From 9a1fd67cb43a0800f2f6df4af65459968f656e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 21 Feb 2024 14:59:08 -0500 Subject: [PATCH] [Flight] Prefix Replayed Console Logs with a Badge (#28403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on top of #28384. This prefixes each log with a badge similar to how we badge built-ins like "ForwardRef" and "Memo" in the React DevTools. The idea is that we can add such badges in DevTools for Server Components too to carry on the consistency. This puts the "environment" name in the badge which defaults to "Server". So you know which source it is coming from. We try to use the same styling as the React DevTools. We use light-dark mode where available to support the two different color styles, but if it's not available I use a fixed background so that it's always readable even in dark mode. In Terminals, instead of hard coding colors that might not look good with some themes, I use the ANSI color code to flip background/foreground colors in that case. In earlier commits I had it on the end of the line similar to the DevTools badges but for multiline I found it better to prefix it. We could try various options tough. In most cases we can use both ANSI and the `%c` CSS color specifier, because node will only use ANSI and hide the other. Chrome supports both but the color overrides ANSI if it comes later (and Chrome doesn't support color inverting anyway). Safari/Firefox prints the ANSI, so it can only use CSS colors. Therefore in browser builds I exclude ANSI. On the server I support both so if you use Chrome inspector on the server, you get nice colors on both terminal and in the inspector. Since Bun uses WebKit inspector and it prints the ANSI we can't safely emit both there. However, we also can't emit just the color specifier because then it prints in the terminal. https://github.com/oven-sh/bun/issues/9021 So we just use a plain string prefix for now with a bracket until that's fixed. Screen shots: Screenshot 2024-02-21 at 12 56 02 AM Screenshot 2024-02-21 at 12 56 24 AM Screenshot 2024-02-21 at 12 57 10 AM Screenshot 2024-02-21 at 12 57 34 AM Screenshot 2024-02-21 at 12 58 23 AM Screenshot 2024-02-21 at 12 58 56 AM --- .../react-client/src/ReactFlightClient.js | 9 +-- .../ReactFlightClientConsoleConfigBrowser.js | 69 ++++++++++++++++++ .../ReactFlightClientConsoleConfigPlain.js | 50 +++++++++++++ .../ReactFlightClientConsoleConfigServer.js | 70 +++++++++++++++++++ .../forks/ReactFlightClientConfig.custom.js | 2 + ...ReactFlightClientConfig.dom-browser-esm.js | 1 + ...lightClientConfig.dom-browser-turbopack.js | 1 + .../ReactFlightClientConfig.dom-browser.js | 1 + .../forks/ReactFlightClientConfig.dom-bun.js | 1 + ...ctFlightClientConfig.dom-edge-turbopack.js | 1 + ...eactFlightClientConfig.dom-edge-webpack.js | 1 + ...tFlightClientConfig.dom-fb-experimental.js | 1 + .../ReactFlightClientConfig.dom-legacy.js | 1 + .../ReactFlightClientConfig.dom-node-esm.js | 1 + ...ClientConfig.dom-node-turbopack-bundled.js | 1 + ...ctFlightClientConfig.dom-node-turbopack.js | 1 + ...eactFlightClientConfig.dom-node-webpack.js | 1 + .../forks/ReactFlightClientConfig.dom-node.js | 1 + .../src/ReactNoopFlightClient.js | 4 ++ .../react-server/src/ReactFlightServer.js | 4 +- .../normalizeConsoleFormat-test.internal.js | 51 ++++++++++++++ packages/shared/normalizeConsoleFormat.js | 56 +++++++++++++++ 22 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js create mode 100644 packages/react-client/src/ReactFlightClientConsoleConfigPlain.js create mode 100644 packages/react-client/src/ReactFlightClientConsoleConfigServer.js create mode 100644 packages/shared/__tests__/normalizeConsoleFormat-test.internal.js create mode 100644 packages/shared/normalizeConsoleFormat.js diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 4bdf579afd77b..63342962e2a08 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -50,6 +50,7 @@ import { readFinalStringChunk, createStringDecoder, prepareDestinationForModule, + printToConsole, } from './ReactFlightClientConfig'; import {registerServerReference} from './ReactFlightReplyClient'; @@ -1094,13 +1095,13 @@ function resolveConsoleEntry( ); } - const payload: [string, string, mixed] = parseModel(response, value); + const payload: [string, string, string, mixed] = parseModel(response, value); const methodName = payload[0]; // TODO: Restore the fake stack before logging. // const stackTrace = payload[1]; - const args = payload.slice(2); - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, args); + const env = payload[2]; + const args = payload.slice(3); + printToConsole(methodName, args, env); } function mergeBuffer( diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js b/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js new file mode 100644 index 0000000000000..cc934685c8f7b --- /dev/null +++ b/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js @@ -0,0 +1,69 @@ +/** + * 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 + */ + +const badgeFormat = '%c%s%c '; +// Same badge styling as DevTools. +const badgeStyle = + // We use a fixed background if light-dark is not supported, otherwise + // we use a transparent background. + 'background: #e6e6e6;' + + 'background: light-dark(rgba(0,0,0,0.1), rgba(255,255,255,0.25));' + + 'color: #000000;' + + 'color: light-dark(#000000, #ffffff);' + + 'border-radius: 2px'; +const resetStyle = ''; +const pad = ' '; + +export function printToConsole( + methodName: string, + args: Array, + badgeName: string, +): void { + let offset = 0; + switch (methodName) { + case 'dir': + case 'dirxml': + case 'groupEnd': + case 'table': { + // These methods cannot be colorized because they don't take a formatting string. + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + return; + } + case 'assert': { + // assert takes formatting options as the second argument. + offset = 1; + } + } + + const newArgs = args.slice(0); + if (typeof newArgs[offset] === 'string') { + newArgs.splice( + offset, + 1, + badgeFormat + newArgs[offset], + badgeStyle, + pad + badgeName + pad, + resetStyle, + ); + } else { + newArgs.splice( + offset, + 0, + badgeFormat, + badgeStyle, + pad + badgeName + pad, + resetStyle, + ); + } + + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + return; +} diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js b/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js new file mode 100644 index 0000000000000..1dbdec54cd078 --- /dev/null +++ b/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js @@ -0,0 +1,50 @@ +/** + * 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 + */ + +const badgeFormat = '[%s] '; +const pad = ' '; + +export function printToConsole( + methodName: string, + args: Array, + badgeName: string, +): void { + let offset = 0; + switch (methodName) { + case 'dir': + case 'dirxml': + case 'groupEnd': + case 'table': { + // These methods cannot be colorized because they don't take a formatting string. + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + return; + } + case 'assert': { + // assert takes formatting options as the second argument. + offset = 1; + } + } + + const newArgs = args.slice(0); + if (typeof newArgs[offset] === 'string') { + newArgs.splice( + offset, + 1, + badgeFormat + newArgs[offset], + pad + badgeName + pad, + ); + } else { + newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad); + } + + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + return; +} diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigServer.js b/packages/react-client/src/ReactFlightClientConsoleConfigServer.js new file mode 100644 index 0000000000000..7567483245fa6 --- /dev/null +++ b/packages/react-client/src/ReactFlightClientConsoleConfigServer.js @@ -0,0 +1,70 @@ +/** + * 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 + */ + +// This flips color using ANSI, then sets a color styling, then resets. +const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c '; +// Same badge styling as DevTools. +const badgeStyle = + // We use a fixed background if light-dark is not supported, otherwise + // we use a transparent background. + 'background: #e6e6e6;' + + 'background: light-dark(rgba(0,0,0,0.1), rgba(255,255,255,0.25));' + + 'color: #000000;' + + 'color: light-dark(#000000, #ffffff);' + + 'border-radius: 2px'; +const resetStyle = ''; +const pad = ' '; + +export function printToConsole( + methodName: string, + args: Array, + badgeName: string, +): void { + let offset = 0; + switch (methodName) { + case 'dir': + case 'dirxml': + case 'groupEnd': + case 'table': { + // These methods cannot be colorized because they don't take a formatting string. + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + return; + } + case 'assert': { + // assert takes formatting options as the second argument. + offset = 1; + } + } + + const newArgs = args.slice(0); + if (typeof newArgs[offset] === 'string') { + newArgs.splice( + offset, + 1, + badgeFormat + newArgs[offset], + badgeStyle, + pad + badgeName + pad, + resetStyle, + ); + } else { + newArgs.splice( + offset, + 0, + badgeFormat, + badgeStyle, + pad + badgeName + pad, + resetStyle, + ); + } + + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + return; +} diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index 152bfd8b1d51b..45a47a8c1405f 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -47,3 +47,5 @@ export opaque type StringDecoder = mixed; // eslint-disable-line no-undef export const createStringDecoder = $$$config.createStringDecoder; export const readPartialStringChunk = $$$config.readPartialStringChunk; export const readFinalStringChunk = $$$config.readFinalStringChunk; + +export const printToConsole = $$$config.printToConsole; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js index 0a29f7eedf12a..87d87ea523e59 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js index 91803e317dda8..97b4afd13a835 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js index cd7c3fe534e6a..51c832bff43a3 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 09b0ba0dce9ef..50713ae8e8e68 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export type Response = any; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js index 47b8682cf605c..269f8ec0c2313 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js index 97205820b3e35..cafa02b686214 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-fb-experimental.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-fb-experimental.js index 798c5035cc38f..7f43b0c2a3cd0 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-fb-experimental.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-fb-experimental.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export * from 'react-server-dom-fb/src/ReactFlightClientConfigFBBundler'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 09b0ba0dce9ef..017dc33081d5f 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; export type Response = any; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js index d18de1bd82750..6c68ae163bb51 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js index e1ea79d9fa652..f1e7d66ee8117 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js index cb9b48e1928c8..c6da80ef6060f 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js index 44713bccd756c..95fd1590ab5c6 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js index 4000698372377..41c7e8e1d4706 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js @@ -8,6 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; +export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 3bd3d863ac45e..afb3eb4760127 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -43,6 +43,10 @@ const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({ parseModel(response: Response, json) { return JSON.parse(json, response._fromJSON); }, + printToConsole(methodName, args, badgeName) { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + }, }); function read(source: Source): Thenable { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4fd42da255c63..5f18f46d7ffe9 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -2213,7 +2213,9 @@ function emitConsoleChunk( } } - const payload = [methodName, stackTrace]; + // TODO: Don't double badge if this log came from another Flight Client. + const env = request.environmentName; + const payload = [methodName, stackTrace, env]; // $FlowFixMe[method-unbinding] payload.push.apply(payload, args); // $FlowFixMe[incompatible-type] stringify can return null diff --git a/packages/shared/__tests__/normalizeConsoleFormat-test.internal.js b/packages/shared/__tests__/normalizeConsoleFormat-test.internal.js new file mode 100644 index 0000000000000..e2cb4c7784704 --- /dev/null +++ b/packages/shared/__tests__/normalizeConsoleFormat-test.internal.js @@ -0,0 +1,51 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let normalizeConsoleFormat; + +describe('normalizeConsoleFormat', () => { + beforeEach(() => { + normalizeConsoleFormat = require('shared/normalizeConsoleFormat').default; + }); + + it('normalize empty string', async () => { + expect(normalizeConsoleFormat('', [1, {}, 'foo'], 0)).toMatchInlineSnapshot( + `"%o %o %s"`, + ); + }); + + it('normalize extra args', async () => { + expect( + normalizeConsoleFormat('%f', [1, {}, 'foo'], 0), + ).toMatchInlineSnapshot(`"%f %o %s"`); + }); + + it('normalize fewer than args', async () => { + expect( + normalizeConsoleFormat('%s %O %o %f', [1, {}, 'foo'], 0), + ).toMatchInlineSnapshot(`"%s %O %o %%f"`); + }); + + it('normalize escape sequences', async () => { + expect( + normalizeConsoleFormat('hel%lo %s %%s world', [1, 'foo'], 0), + ).toMatchInlineSnapshot(`"hel%lo %s %%s world %s"`); + }); + + it('normalize ending with escape', async () => { + expect( + normalizeConsoleFormat('hello %s world %', [1, {}, 'foo'], 0), + ).toMatchInlineSnapshot(`"hello %s world % %o %s"`); + expect( + normalizeConsoleFormat('hello %s world %', [], 0), + ).toMatchInlineSnapshot(`"hello %%s world %"`); + }); +}); diff --git a/packages/shared/normalizeConsoleFormat.js b/packages/shared/normalizeConsoleFormat.js new file mode 100644 index 0000000000000..f224647425a0a --- /dev/null +++ b/packages/shared/normalizeConsoleFormat.js @@ -0,0 +1,56 @@ +/** + * 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 + */ + +// Takes a format string (first argument to console) and returns a normalized +// string that has the exact number of arguments as the args. That way it's safe +// to prepend or append to it. +export default function normalizeConsoleFormat( + formatString: string, + args: $ReadOnlyArray, + firstArg: number, +): string { + let j = firstArg; + let normalizedString = ''; + let last = 0; + for (let i = 0; i < formatString.length - 1; i++) { + if (formatString.charCodeAt(i) !== 37 /* "%" */) { + continue; + } + switch (formatString.charCodeAt(++i)) { + case 79 /* "O" */: + case 99 /* "c" */: + case 100 /* "d" */: + case 102 /* "f" */: + case 105 /* "i" */: + case 111 /* "o" */: + case 115 /* "s" */: { + if (j < args.length) { + // We have a matching argument. + j++; + } else { + // We have more format specifiers than arguments. + // So we need to escape this to print the literal. + normalizedString += formatString.slice(last, (last = i)) + '%'; + } + } + } + } + normalizedString += formatString.slice(last, formatString.length); + // Pad with extra format specifiers for the rest. + while (j < args.length) { + if (normalizedString !== '') { + normalizedString += ' '; + } + // Not every environment has the same default. + // This seems to be what Chrome DevTools defaults to. + normalizedString += typeof args[j] === 'string' ? '%s' : '%o'; + j++; + } + return normalizedString; +}