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; +}