Repository: vercel/next.js
Pull request: Dim console logs from prospective prerender in dev

Same approach as React's StrictMode dimming (including its bugs). E.g. `console.log(obj)` will stringify `obj`. A string as the first arg will assume it's a template string which results in subsequent args not being dimmed. I'll spend a bit of time on trying to fix those but this should be good enough as a start.

Closes https://linear.app/vercel/issue/NAR-145/dim-prerender-logs-in-dev

Terminal:
<img width="1220" height="1250" alt="CleanShot 2025-07-11 at 15 51 29@2x" src="https://github.com/user-attachments/assets/36b3b125-72f3-4867-b66d-d7fdd5acdb8b" />



Chrome:
<img width="1778" height="1506" alt="CleanShot 2025-07-11 at 15 50 47@2x" src="https://github.com/user-attachments/assets/d49ba069-f876-4b71-a544-103d5821c098" />


Changed files:
filename: packages/next/src/server/node-environment-extensions/console-dev.tsx
patch: @@ -0,0 +1,163 @@
+import { dim } from '../../lib/picocolors'
+import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external'
+
+type InterceptableConsoleMethod =
+  | 'error'
+  | 'assert'
+  | 'debug'
+  | 'dir'
+  | 'dirxml'
+  | 'group'
+  | 'groupCollapsed'
+  | 'groupEnd'
+  | 'info'
+  | 'log'
+  | 'table'
+  | '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 args = inputArgs.slice()
+
+  let template = ''
+  let argumentsPointer = 0
+  for (let i = 0; i < maybeMessage.length; ++i) {
+    const currentChar = maybeMessage[i]
+    if (currentChar !== '%') {
+      template += currentChar
+      continue
+    }
+
+    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
+      }
+      case 'd':
+      case 'i': {
+        const [arg] = args.splice(argumentsPointer, 1)
+        template += parseInt(arg, 10).toString()
+
+        break
+      }
+      case 'f': {
+        const [arg] = args.splice(argumentsPointer, 1)
+        template += parseFloat(arg).toString()
+
+        break
+      }
+      case 's': {
+        const [arg] = args.splice(argumentsPointer, 1)
+        template += String(arg)
+
+        break
+      }
+
+      default:
+        template += `%${nextChar}`
+    }
+  }
+
+  return [template, ...args]
+}
+
+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'
+
+function dimConsoleCall(
+  methodName: InterceptableConsoleMethod,
+  args: any[]
+): any[] {
+  switch (methodName) {
+    case 'dir':
+    case 'dirxml':
+    case 'groupEnd':
+    case 'table': {
+      // These methods cannot be colorized because they don't take a formatting string.
+      return args
+    }
+    case 'assert': {
+      // assert takes formatting options as the second argument.
+      return [args[0]].concat(
+        ANSI_STYLE_DIMMING_TEMPLATE,
+        ...formatConsoleArguments(args[1], ...args.slice(2))
+      )
+    }
+    default:
+      return [ANSI_STYLE_DIMMING_TEMPLATE].concat(
+        ...formatConsoleArguments(args[0], ...args.slice(1))
+      )
+  }
+}
+
+// Based on https://github.com/facebook/react/blob/28dc0776be2e1370fe217549d32aee2519f0cf05/packages/react-server/src/ReactFlightServer.js#L248
+function patchConsoleMethodDEV(methodName: InterceptableConsoleMethod): void {
+  const descriptor = Object.getOwnPropertyDescriptor(console, methodName)
+  if (
+    descriptor &&
+    (descriptor.configurable || descriptor.writable) &&
+    typeof descriptor.value === 'function'
+  ) {
+    const originalMethod = descriptor.value
+    const originalName = Object.getOwnPropertyDescriptor(originalMethod, 'name')
+    const wrapperMethod = function (this: typeof console, ...args: any[]) {
+      const workUnitStore = workUnitAsyncStorage.getStore()
+      const isPrerenderValidation =
+        workUnitStore !== undefined &&
+        (workUnitStore.type === 'prerender-client' ||
+          workUnitStore.type === 'prerender')
+
+      if (isPrerenderValidation) {
+        originalMethod.apply(this, dimConsoleCall(methodName, args))
+      } else {
+        originalMethod.apply(this, args)
+      }
+    }
+    if (originalName) {
+      Object.defineProperty(wrapperMethod, 'name', originalName)
+    }
+    Object.defineProperty(console, methodName, {
+      value: wrapperMethod,
+    })
+  }
+}
+
+patchConsoleMethodDEV('error')
+patchConsoleMethodDEV('assert')
+patchConsoleMethodDEV('debug')
+patchConsoleMethodDEV('dir')
+patchConsoleMethodDEV('dirxml')
+patchConsoleMethodDEV('group')
+patchConsoleMethodDEV('groupCollapsed')
+patchConsoleMethodDEV('groupEnd')
+patchConsoleMethodDEV('info')
+patchConsoleMethodDEV('log')
+patchConsoleMethodDEV('table')
+patchConsoleMethodDEV('trace')
+patchConsoleMethodDEV('warn')

filename: packages/next/src/server/node-environment.ts
patch: @@ -9,3 +9,7 @@ import './node-environment-extensions/random'
 import './node-environment-extensions/date'
 import './node-environment-extensions/web-crypto'
 import './node-environment-extensions/node-crypto'
+
+if (process.env.NODE_ENV === 'development') {
+  require('./node-environment-extensions/console-dev') as typeof import('./node-environment-extensions/console-dev')
+}

filename: test/e2e/app-dir/dynamic-io/app/console/page.tsx
patch: @@ -0,0 +1,13 @@
+export default function ConsolePage() {
+  console.info('/console: template(one: %s, two: %s)', 'one', 'two')
+  console.log('/console: This is a console page')
+  console.warn('/console: not a template', { foo: 'just-some-object' })
+  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')
+  return null
+}

filename: test/e2e/app-dir/dynamic-io/dynamic-io.console.test.ts
patch: @@ -0,0 +1,100 @@
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+import stripAnsi from 'strip-ansi'
+
+describe('dynamic-io', () => {
+  const { isNextDev, isTurbopack, next, skipped } = nextTestSetup({
+    env: {
+      FORCE_COLOR: '1',
+    },
+    files: __dirname,
+    skipDeployment: true,
+  })
+
+  if (skipped) {
+    return
+  }
+
+  it('dims console calls during prospective rendering', async () => {
+    const browser = await next.browser('/console', {})
+
+    if (isNextDev) {
+      await retry(() => {
+        expect(stripAnsi(next.cliOutput)).toContain('GET /console 200')
+      })
+
+      // do not strip ANSI codes here since we're explicitly testing coloring.
+      const cliOutputFromPage = next.cliOutput.match(
+        /Compiled \/console[^\n]+\n(.*)\n GET \/console /s
+      )[1]
+
+      expect(cliOutputFromPage).toMatchInlineSnapshot(`
+       "/console: template(one: one, two: two)
+       /console: This is a console page
+       /console: not a template { foo: [32m'just-some-object'[39m }
+       Error: /console: test
+           at ConsolePage (app/console/page.tsx:5:16)
+       [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
+       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:16)
+         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:16)
+         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"
+      `)
+      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'))
+           |                 ^",
+         "stack": [
+           "ConsolePage app/console/page.tsx (5:17)",
+           "ConsolePage <anonymous>",
+         ],
+       }
+      `)
+    } else {
+      // prewarm + render
+      // 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
+      )
+    }
+  })
+})