Repository: vercel/next.js
Pull request: Handle badged console calls when dimming console output

Closes https://linear.app/vercel/issue/NAR-227/

Rewrite of the current implementation to accomodate badges added by React which reset style. We previously misplaced color placeholders when we attempted to dim replayed console calls from Cache environments.

Terminal:
Badges aren't dimmed. I'll follow-up on that.
<img width="1728" height="1262" alt="CleanShot 2025-07-21 at 15 08 11@2x" src="https://github.com/user-attachments/assets/84d82d76-74e8-4b68-b5ab-c1c86524ecc6" />

Chrome debugger:
<img width="2010" height="1836" alt="CleanShot 2025-07-21 at 15 08 51@2x" src="https://github.com/user-attachments/assets/e7d876bd-ad06-42a7-88be-63ffc1b81816" />


The way Chrome aligns chevrons and text is unfortunate. I'll file a crbug.


Changed files:
filename: packages/next/src/server/node-environment-extensions/console-dev.tsx
patch: @@ -16,79 +16,111 @@ type InterceptableConsoleMethod =
   | 'trace'
   | 'warn'
 
-// 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
-/**
- * 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.
- *
- * @source https://github.com/facebook/react/blob/b44a99bf58d69d52b5288d9eadcc6d226d705e11/packages/react-devtools-shared/src/backend/utils/formatConsoleArguments.js#L14
- */
-function formatConsoleArguments(maybeMessage: any, ...inputArgs: any[]): any[] {
-  if (inputArgs.length === 0 || typeof maybeMessage !== 'string') {
-    return [maybeMessage, ...inputArgs]
-  }
+const isColorSupported = dim('test') !== 'test'
+
+// 50% opacity for dimmed text
+const dimStyle = 'color: color(from currentColor xyz x y z / 0.5);'
+const reactBadgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c '
 
-  const args = inputArgs.slice()
+function dimmedConsoleArgs(...inputArgs: any[]): any[] {
+  if (!isColorSupported) {
+    return inputArgs
+  }
 
+  const newArgs = inputArgs.slice(0)
   let template = ''
   let argumentsPointer = 0
-  for (let i = 0; i < maybeMessage.length; ++i) {
-    const currentChar = maybeMessage[i]
-    if (currentChar !== '%') {
-      template += currentChar
-      continue
+  if (typeof inputArgs[0] === 'string') {
+    const originalTemplateString = inputArgs[0]
+    // Remove the original template string from the args.
+    newArgs.splice(argumentsPointer, 1)
+    argumentsPointer += 1
+
+    let i = 0
+    if (originalTemplateString.startsWith(reactBadgeFormat)) {
+      i = reactBadgeFormat.length
+      // for `format` we already moved the pointer earlier
+      // style, badge, reset style
+      argumentsPointer += 3
+      template += reactBadgeFormat
+      // React's badge reset styles, reapply dimming
+      template += '\x1b[2m%c'
+      // argumentsPointer includes template
+      newArgs.splice(argumentsPointer - 1, 0, dimStyle)
+      // dim the badge
+      newArgs[0] += `;${dimStyle}`
     }
 
-    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
+    for (i; i < originalTemplateString.length; i++) {
+      const currentChar = originalTemplateString[i]
+      if (currentChar !== '%') {
+        template += currentChar
+        continue
       }
-      case 'd':
-      case 'i': {
-        const [arg] = args.splice(argumentsPointer, 1)
-        template += parseInt(arg, 10).toString()
 
-        break
+      const nextChar = originalTemplateString[i + 1]
+      ++i
+
+      switch (nextChar) {
+        case 'f':
+        case 'O':
+        case 'o':
+        case 'd':
+        case 's':
+        case 'i':
+        case 'c':
+          ++argumentsPointer
+          template += `%${nextChar}`
+          break
+        default:
+          template += `%${nextChar}`
       }
-      case 'f': {
-        const [arg] = args.splice(argumentsPointer, 1)
-        template += parseFloat(arg).toString()
+    }
+  }
 
+  for (
+    argumentsPointer;
+    argumentsPointer < inputArgs.length;
+    ++argumentsPointer
+  ) {
+    const arg = inputArgs[argumentsPointer]
+    const argType = typeof arg
+    if (argumentsPointer > 0) {
+      template += ' '
+    }
+    switch (argType) {
+      case 'boolean':
+      case 'string':
+        template += '%s'
         break
-      }
-      case 's': {
-        const [arg] = args.splice(argumentsPointer, 1)
-        template += String(arg)
-
+      case 'bigint':
+        template += '%s'
+        break
+      case 'number':
+        if (arg % 0) {
+          template += '%f'
+        } else {
+          template += '%d'
+        }
+        break
+      case 'object':
+        template += '%O'
+        break
+      case 'symbol':
+      case 'undefined':
+      case 'function':
+        template += '%s'
         break
-      }
-
       default:
-        template += `%${nextChar}`
+        // deopt to string for new, unknown types
+        template += '%s'
     }
   }
 
-  return [template, ...args]
-}
+  template += '\x1b[22m'
 
-const isColorSupported = dim('test') !== 'test'
-// TODO: Breaks when complex objects are logged
-// dim("%s") does not work in Chrome
-const ANSI_STYLE_DIMMING_TEMPLATE = isColorSupported
-  ? '\x1b[2;38;2;124;124;124m%s\x1b[0m'
-  : '%s'
+  return [dim(`%c${template}`), dimStyle, ...newArgs]
+}
 
 function dimConsoleCall(
   methodName: InterceptableConsoleMethod,
@@ -106,20 +138,15 @@ function dimConsoleCall(
     }
     case 'assert': {
       // assert takes formatting options as the second argument.
-      return [args[0]].concat(
-        ANSI_STYLE_DIMMING_TEMPLATE,
-        ...formatConsoleArguments(args[1], ...args.slice(2))
-      )
+      return [args[0]].concat(...dimmedConsoleArgs(args[1], ...args.slice(2)))
     }
     case 'error':
     case 'debug':
     case 'info':
     case 'log':
     case 'trace':
     case 'warn':
-      return [ANSI_STYLE_DIMMING_TEMPLATE].concat(
-        ...formatConsoleArguments(args[0], ...args.slice(1))
-      )
+      return dimmedConsoleArgs(args[0], ...args.slice(1))
     default:
       return methodName satisfies never
   }

filename: test/e2e/app-dir/cache-components/app/console/page.tsx
patch: @@ -1,6 +1,27 @@
-export default function ConsolePage() {
+async function cachedConsoleCalls() {
+  'use cache'
   console.info('/console: template(one: %s, two: %s)', 'one', 'two')
-  console.log('/console: This is a console page')
+  console.log(
+    // eslint-disable-next-line no-useless-concat
+    '/console: This is a console page' + ". Don't match the codeframe."
+  )
+  console.warn('/console: not a template', { foo: 'just-some-object' })
+  // TODO(veil): Assert on inspected errors once we sourcemap errors replayed from Cache environment.
+  // console.error(new Error('/console: test'))
+  console.assert(
+    false,
+    '/console: This is an assert message with a %s',
+    'template'
+  )
+  console.assert(true, '/console: This is an assert message without a template')
+}
+
+export default async function ConsolePage() {
+  console.info('/console: template(one: %s, two: %s)', 'one', 'two')
+  console.log(
+    // eslint-disable-next-line no-useless-concat
+    '/console: This is a console page' + ". Don't match the codeframe."
+  )
   console.warn('/console: not a template', { foo: 'just-some-object' })
   console.error(new Error('/console: test'))
   console.assert(
@@ -9,5 +30,8 @@ export default function ConsolePage() {
     'template'
   )
   console.assert(true, '/console: This is an assert message without a template')
+
+  await cachedConsoleCalls()
+
   return null
 }

filename: test/e2e/app-dir/cache-components/cache-components.console.test.ts
patch: @@ -3,7 +3,7 @@ import { retry } from 'next-test-utils'
 import stripAnsi from 'strip-ansi'
 
 describe('cache-components', () => {
-  const { isNextDev, isTurbopack, next, skipped } = nextTestSetup({
+  const { isNextDev, next, skipped } = nextTestSetup({
     env: {
       FORCE_COLOR: '1',
     },
@@ -30,71 +30,88 @@ describe('cache-components', () => {
 
       expect(cliOutputFromPage).toMatchInlineSnapshot(`
        "/console: template(one: one, two: two)
-       /console: This is a console page
+       /console: This is a console page. Don't match the codeframe.
        /console: not a template { foo: [32m'just-some-object'[39m }
        Error: /console: test
-           at ConsolePage (app/console/page.tsx:5:17)
-       [0m [90m 3 |[39m   console[33m.[39mlog([32m'/console: This is a console page'[39m)
-        [90m 4 |[39m   console[33m.[39mwarn([32m'/console: not a template'[39m[33m,[39m { foo[33m:[39m [32m'just-some-object'[39m })
-       [31m[1m>[22m[39m[90m 5 |[39m   console[33m.[39merror([36mnew[39m [33mError[39m([32m'/console: test'[39m))
-        [90m   |[39m                 [31m[1m^[22m[39m
-        [90m 6 |[39m   console[33m.[39massert(
-        [90m 7 |[39m     [36mfalse[39m[33m,[39m
-        [90m 8 |[39m     [32m'/console: This is an assert message with a %s'[39m[33m,[39m[0m
+           at ConsolePage (app/console/page.tsx:26:17)
+       [0m [90m 24 |[39m   )
+        [90m 25 |[39m   console[33m.[39mwarn([32m'/console: not a template'[39m[33m,[39m { foo[33m:[39m [32m'just-some-object'[39m })
+       [31m[1m>[22m[39m[90m 26 |[39m   console[33m.[39merror([36mnew[39m [33mError[39m([32m'/console: test'[39m))
+        [90m    |[39m                 [31m[1m^[22m[39m
+        [90m 27 |[39m   console[33m.[39massert(
+        [90m 28 |[39m     [36mfalse[39m[33m,[39m
+        [90m 29 |[39m     [32m'/console: This is an assert message with a %s'[39m[33m,[39m[0m
        Assertion failed: /console: This is an assert message with a template
-       [2;38;2;124;124;124m/console: template(one: one, two: two)[0m
-       [2;38;2;124;124;124m/console: This is a console page[0m
-       [2;38;2;124;124;124m/console: not a template[0m { foo: [32m'just-some-object'[39m }
-       [2;38;2;124;124;124mError: /console: test
-           at ConsolePage (app/console/page.tsx:5:17)
-         3 |   console.log('/console: This is a console page')
-         4 |   console.warn('/console: not a template', { foo: 'just-some-object' })
-       > 5 |   console.error(new Error('/console: test'))
-           |                 ^
-         6 |   console.assert(
-         7 |     false,
-         8 |     '/console: This is an assert message with a %s',[0m
-       [2;38;2;124;124;124mAssertion failed: [2;38;2;124;124;124m/console: This is an assert message with a template[0m[0m
-       [2;38;2;124;124;124m/console: template(one: one, two: two)[0m
-       [2;38;2;124;124;124m/console: This is a console page[0m
-       [2;38;2;124;124;124m/console: not a template[0m { foo: [32m'just-some-object'[39m }
-       [2;38;2;124;124;124mError: /console: test
-           at ConsolePage (app/console/page.tsx:5:17)
-         3 |   console.log('/console: This is a console page')
-         4 |   console.warn('/console: not a template', { foo: 'just-some-object' })
-       > 5 |   console.error(new Error('/console: test'))
-           |                 ^
-         6 |   console.assert(
-         7 |     false,
-         8 |     '/console: This is an assert message with a %s',[0m
-       [2;38;2;124;124;124mAssertion failed: [2;38;2;124;124;124m/console: This is an assert message with a template[0m[0m"
+       /console: template(one: one, two: two)
+       /console: This is a console page. Don't match the codeframe.
+       /console: not a template { foo: [32m'just-some-object'[39m }
+       Assertion failed: /console: This is an assert message with a template
+       [0m[7m Cache [0m /console: template(one: one, two: two)
+       [0m[7m Cache [0m /console: This is a console page. Don't match the codeframe.
+       [0m[7m Cache [0m /console: not a template { foo: [32m'just-some-object'[39m }
+       Assertion failed: [0m[7m Cache [0m /console: This is an assert message with a template
+       [0m[7m Cache [0m Assertion failed: /console: This is an assert message with a template
+       [2m/console: template(one: one, two: two)[22m[2m[22m
+       [2m/console: This is a console page. Don't match the codeframe.[22m[2m[22m
+       [2m/console: not a template { foo: [32m'just-some-object'[39m }[22m[2m[22m
+       [2mError: /console: test
+           at ConsolePage (app/console/page.tsx:26:17)
+       [0m [90m 24 |[39m   )
+        [90m 25 |[39m   console[33m.[39mwarn([32m'/console: not a template'[39m[33m,[39m { foo[33m:[39m [32m'just-some-object'[39m })
+       [31m[1m>[22m[39m[90m 26 |[39m   console[33m.[39merror([36mnew[39m [33mError[39m([32m'/console: test'[39m))
+        [90m    |[39m                 [31m[1m^[22m[39m
+        [90m 27 |[39m   console[33m.[39massert(
+        [90m 28 |[39m     [36mfalse[39m[33m,[39m
+        [90m 29 |[39m     [32m'/console: This is an assert message with a %s'[39m[33m,[39m[0m[22m[2m[22m
+       [2mAssertion failed: [2m/console: This is an assert message with a template[22m[2m[2m[22m[2m[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: template(one: one, two: two)[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: This is a console page. Don't match the codeframe.[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: not a template { foo: [32m'just-some-object'[39m }[22m[2m[22m
+       [2mAssertion failed: [2m[0m[7m Cache [0m [2m/console: This is an assert message with a template[22m[2m[2m[22m[2m[22m[2m[22m
+       [2m[0m[7m Cache [0m [2mAssertion failed: /console: This is an assert message with a template[22m[2m[22m
+       [2m/console: template(one: one, two: two)[22m[2m[22m
+       [2m/console: This is a console page. Don't match the codeframe.[22m[2m[22m
+       [2m/console: not a template { foo: [32m'just-some-object'[39m }[22m[2m[22m
+       [2mError: /console: test
+           at ConsolePage (app/console/page.tsx:26:17)
+       [0m [90m 24 |[39m   )
+        [90m 25 |[39m   console[33m.[39mwarn([32m'/console: not a template'[39m[33m,[39m { foo[33m:[39m [32m'just-some-object'[39m })
+       [31m[1m>[22m[39m[90m 26 |[39m   console[33m.[39merror([36mnew[39m [33mError[39m([32m'/console: test'[39m))
+        [90m    |[39m                 [31m[1m^[22m[39m
+        [90m 27 |[39m   console[33m.[39massert(
+        [90m 28 |[39m     [36mfalse[39m[33m,[39m
+        [90m 29 |[39m     [32m'/console: This is an assert message with a %s'[39m[33m,[39m[0m[22m[2m[22m
+       [2mAssertion failed: [2m/console: This is an assert message with a template[22m[2m[2m[22m[2m[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: template(one: one, two: two)[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: This is a console page. Don't match the codeframe.[22m[2m[22m
+       [2m[0m[7m Cache [0m [2m/console: not a template { foo: [32m'just-some-object'[39m }[22m[2m[22m
+       [2mAssertion failed: [2m[0m[7m Cache [0m [2m/console: This is an assert message with a template[22m[2m[2m[22m[2m[22m[2m[22m
+       [2m[0m[7m Cache [0m [2mAssertion failed: /console: This is an assert message with a template[22m[2m[22m"
       `)
+
       await expect(browser).toDisplayCollapsedRedbox(`
        {
          "description": "/console: test",
          "environmentLabel": "Prerender",
          "label": "Console Error",
-         "source": "app/console/page.tsx (5:17) @ ConsolePage
-       > 5 |   console.error(new Error('/console: test'))
-           |                 ^",
+         "source": "app/console/page.tsx (26:17) @ ConsolePage
+       > 26 |   console.error(new Error('/console: test'))
+            |                 ^",
          "stack": [
-           "ConsolePage app/console/page.tsx (5:17)",
+           "ConsolePage app/console/page.tsx (26:17)",
            "ConsolePage <anonymous>",
          ],
        }
       `)
     } else {
-      // prewarm + render
+      // prewarm + render + Cache replay
       // Neither is dimmed in production
       const pageInvocations = Array.from(
-        next.cliOutput.matchAll(/\/console: This is a console page/g)
-      )
-      expect(pageInvocations).toHaveLength(
-        isTurbopack
-          ? // TODO: Why?
-            4
-          : 2
+        next.cliOutput.matchAll(
+          /\/console: This is a console page\. Don't match the codeframe\./g
+        )
       )
+      expect(pageInvocations).toHaveLength(3)
     }
   })
 })