Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -356,5 +356,73 @@ describe('console-exit patches', () => {
{ method: 'log', input: '[BRIGHT]: with null signal' },
])
})

it('should output console.trace with a clean stack trace without internal wrapper frames', async () => {
async function testForWorker() {
let capturedOutput = ''

// Replace console.error to capture the trace output (console.trace uses console.error internally after our patch)
console.error = function (...args) {
capturedOutput = args.join(' ')
reportResult({
type: 'console-call',
method: 'error',
input: capturedOutput,
})
}

// Install patches - this wraps console.trace
require('next/dist/server/node-environment-extensions/console-dim.external')

// Call console.trace - this should output a clean stack trace
console.trace('Test message')

// Report whether the stack trace contains internal wrapper frames
const hasInternalFrames = capturedOutput.includes(
'/node-environment-extensions/'
)
reportResult({
type: 'serialized',
key: 'hasInternalFrames',
data: JSON.stringify(hasInternalFrames),
})

// Report whether the stack trace contains the expected "Trace:" prefix
const hasTracePrefix = capturedOutput.startsWith('Trace:')
reportResult({
type: 'serialized',
key: 'hasTracePrefix',
data: JSON.stringify(hasTracePrefix),
})

// Report whether the stack trace contains the test message
const hasTestMessage = capturedOutput.includes('Test message')
reportResult({
type: 'serialized',
key: 'hasTestMessage',
data: JSON.stringify(hasTestMessage),
})

// Report whether the stack trace contains "at" lines (actual stack frames)
const hasStackFrames = capturedOutput.includes(' at ')
reportResult({
type: 'serialized',
key: 'hasStackFrames',
data: JSON.stringify(hasStackFrames),
})
}

const { data, exitCode } = await runWorkerCode(testForWorker)

expect(exitCode).toBe(0)
// The stack trace should NOT contain internal wrapper frames
expect(data.hasInternalFrames).toBe(false)
// The output should have the "Trace:" prefix
expect(data.hasTracePrefix).toBe(true)
// The output should contain the test message
expect(data.hasTestMessage).toBe(true)
// The output should contain actual stack frames
expect(data.hasStackFrames).toBe(true)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,28 @@ function convertToDimmedArgs(
}
}

/**
* For console.trace, we need special handling because it captures the stack trace
* at the point where it's called. When wrapped by multiple patching functions,
* the stack trace includes all the wrapper frames which pollutes the output.
*
* This function captures a clean stack trace, filters out internal Next.js frames,
* and formats it like console.trace would.
*/
function getCleanStackTrace(): string {
const err = new Error()
const stack = err.stack || ''
const lines = stack.split('\n')

// Filter out internal wrapper frames from node-environment-extensions
const filteredLines = lines.filter((line) => {
return !line.includes('/node-environment-extensions/')
})

// Remove the "Error" header line and join
return filteredLines.slice(1).join('\n')
}

// Based on https://github.com/facebook/react/blob/28dc0776be2e1370fe217549d32aee2519f0cf05/packages/react-server/src/ReactFlightServer.js#L248
function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
const descriptor = Object.getOwnPropertyDescriptor(console, methodName)
Expand All @@ -181,6 +203,13 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
const wrapperMethod = function (this: typeof console, ...args: any[]) {
const consoleStore = consoleAsyncStorage.getStore()

// Special handling for console.trace: capture the stack trace early
// before the wrapper chain pollutes it, then use console.error to output
let traceStack: string | undefined
if (methodName === 'trace') {
traceStack = getCleanStackTrace()
}

// First we see if there is a cache signal for our current scope. If we're in a client render it'll
// come from the client React cacheSignal implementation. If we are in a server render it'll come from
// the server React cacheSignal implementation. Any particular console call will be in one, the other, or neither
Expand All @@ -200,18 +229,26 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else if (consoleStore?.dim === true) {
return applyWithDimming.call(
this,
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else {
return originalMethod.apply(this, args)
return applyMethod.call(
this,
originalMethod,
methodName,
args,
traceStack
)
}
}

Expand Down Expand Up @@ -239,7 +276,8 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
}
// intentional fallthrough
Expand All @@ -255,10 +293,17 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else {
return originalMethod.apply(this, args)
return applyMethod.call(
this,
originalMethod,
methodName,
args,
traceStack
)
}
default:
workUnitStore satisfies never
Expand All @@ -273,13 +318,47 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
}
}

/**
* Helper to apply the console method, with special handling for console.trace
*/
function applyMethod<F extends (this: Console, ...args: any[]) => any>(
this: Console,
method: F,
methodName: InterceptableConsoleMethod,
args: Parameters<F>,
traceStack?: string
): ReturnType<F> {
if (methodName === 'trace' && traceStack !== undefined) {
// For console.trace, output the label + clean stack using console.error (stderr)
const label = args.length > 0 ? `Trace: ${args.join(' ')}` : 'Trace'
return console.error(`${label}\n${traceStack}`) as ReturnType<F>
}
return method.apply(this, args)
}

function applyWithDimming<F extends (this: Console, ...args: any[]) => any>(
this: Console,
consoleStore: undefined | ConsoleStore,
method: F,
methodName: InterceptableConsoleMethod,
args: Parameters<F>
args: Parameters<F>,
traceStack?: string
): ReturnType<F> {
// Special handling for console.trace with clean stack
if (methodName === 'trace' && traceStack !== undefined) {
const label = args.length > 0 ? `Trace: ${args.join(' ')}` : 'Trace'
const traceOutput = `${label}\n${traceStack}`
// Use console.error with the dimmed trace output (outputs to stderr like native trace)
const dimmedArgs = convertToDimmedArgs('error', [traceOutput])
if (consoleStore?.dim === true) {
return console.error(...dimmedArgs) as ReturnType<F>
} else {
return consoleAsyncStorage.run(DIMMED_STORE, () =>
console.error(...dimmedArgs)
) as ReturnType<F>
}
}

if (consoleStore?.dim === true) {
return method.apply(this, convertToDimmedArgs(methodName, args))
} else {
Expand Down
Loading