From 1fb83619d87fef08058236c3d5fa17b94a6b9cf1 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 29 Oct 2024 15:24:55 +0100 Subject: [PATCH] Compare error stack to dedupe error (#71798) --- .../globals/intercept-console-error.ts | 11 ++- .../internal/helpers/capture-stack-trace.ts | 12 ++++ .../internal/helpers/enqueue-client-error.ts | 4 +- .../internal/helpers/stitched-error.ts | 1 - .../app/browser/render-loop/page.js | 8 +++ .../capture-console-error.test.ts | 68 ++++++++++++++----- test/lib/next-test-utils.ts | 2 +- 7 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 packages/next/src/client/components/react-dev-overlay/internal/helpers/capture-stack-trace.ts create mode 100644 test/development/app-dir/capture-console-error/app/browser/render-loop/page.js diff --git a/packages/next/src/client/components/globals/intercept-console-error.ts b/packages/next/src/client/components/globals/intercept-console-error.ts index eea74a4908682..5eb5f8c814151 100644 --- a/packages/next/src/client/components/globals/intercept-console-error.ts +++ b/packages/next/src/client/components/globals/intercept-console-error.ts @@ -1,5 +1,6 @@ import isError from '../../../lib/is-error' import { isNextRouterError } from '../is-next-router-error' +import { captureStackTrace } from '../react-dev-overlay/internal/helpers/capture-stack-trace' import { handleClientError } from '../react-dev-overlay/internal/helpers/use-error-handler' export const originConsoleError = window.console.error @@ -10,13 +11,14 @@ export function patchConsoleError() { if (typeof window === 'undefined') { return } - - window.console.error = (...args: any[]) => { + window.console.error = function error(...args: any[]) { let maybeError: unknown + let isReplayedError = false if (process.env.NODE_ENV !== 'production') { const replayedError = matchReplayedError(...args) if (replayedError) { + isReplayedError = true maybeError = replayedError } else { // See https://github.com/facebook/react/blob/d50323eb845c5fde0d720cae888bf35dedd05506/packages/react-reconciler/src/ReactFiberErrorLogger.js#L78 @@ -28,6 +30,11 @@ export function patchConsoleError() { if (!isNextRouterError(maybeError)) { if (process.env.NODE_ENV !== 'production') { + // Create an origin stack that pointing to the origin location of the error + if (!isReplayedError && isError(maybeError)) { + captureStackTrace(maybeError) + } + handleClientError( // replayed errors have their own complex format string that should be used, // but if we pass the error directly, `handleClientError` will ignore it diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/capture-stack-trace.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/capture-stack-trace.ts new file mode 100644 index 0000000000000..d36343837e96f --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/capture-stack-trace.ts @@ -0,0 +1,12 @@ +// Polyfill for `Error.captureStackTrace` in browsers +export function captureStackTrace(obj: any) { + const container = new Error() + Object.defineProperty(obj, 'stack', { + configurable: true, + get() { + const { stack } = container + Object.defineProperty(this, 'stack', { value: stack }) + return stack + }, + }) +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/enqueue-client-error.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/enqueue-client-error.ts index c70f6fb9ccad7..bc1009207eabe 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/enqueue-client-error.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/enqueue-client-error.ts @@ -7,8 +7,8 @@ export function enqueueConsecutiveDedupedError( ) { const isFront = isHydrationError(error) const previousError = isFront ? queue[0] : queue[queue.length - 1] - // Only check message to see if it's the same error, as message is representative display in the console. - if (previousError && previousError.message === error.message) { + // Compare the error stack to dedupe the consecutive errors + if (previousError && previousError.stack === error.stack) { return } // TODO: change all to push error into errorQueue, diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts index bc64a43769846..3e40528344e8c 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts @@ -10,7 +10,6 @@ export function getReactStitchedError(err: T): Error | T { if (typeof (React as any).captureOwnerStack !== 'function') { return err } - const isErrorInstance = isError(err) const originStack = isErrorInstance ? err.stack || '' : '' const originMessage = isErrorInstance ? err.message : '' diff --git a/test/development/app-dir/capture-console-error/app/browser/render-loop/page.js b/test/development/app-dir/capture-console-error/app/browser/render-loop/page.js new file mode 100644 index 0000000000000..52817bb4e8941 --- /dev/null +++ b/test/development/app-dir/capture-console-error/app/browser/render-loop/page.js @@ -0,0 +1,8 @@ +'use client' + +export default function Page() { + for (let i = 0; i < 3; i++) { + console.error('trigger an console.error in loop of render') + } + return

render

+} diff --git a/test/development/app-dir/capture-console-error/capture-console-error.test.ts b/test/development/app-dir/capture-console-error/capture-console-error.test.ts index c51327066fc50..bc5d8b627d5ba 100644 --- a/test/development/app-dir/capture-console-error/capture-console-error.test.ts +++ b/test/development/app-dir/capture-console-error/capture-console-error.test.ts @@ -55,11 +55,15 @@ describe('app-dir - capture-console-error', () => { 10 | click to error", } `) - } else if (isReactExperimental) { + } else { expect(result).toMatchInlineSnapshot(` { - "callStacks": "button - app/browser/event/page.js (5:6)", + "callStacks": ${ + isReactExperimental + ? `"button + app/browser/event/page.js (5:6)"` + : `""` + }, "count": 1, "description": "trigger an console ", "source": "app/browser/event/page.js (7:17) @ error @@ -73,27 +77,55 @@ describe('app-dir - capture-console-error', () => { 10 | click to error", } `) + } + }) + + it('should capture browser console error in render and dedupe if necessary', async () => { + const browser = await next.browser('/browser/render') + + await waitForAndOpenRuntimeError(browser) + await assertHasRedbox(browser) + + const result = await getRedboxResult(browser) + + if (process.env.TURBOPACK) { + expect(result).toMatchInlineSnapshot(` + { + "callStacks": "", + "count": ${isReactExperimental ? 1 : 2}, + "description": "trigger an console.error in render", + "source": "app/browser/render/page.js (4:11) @ Page + + 2 | + 3 | export default function Page() { + > 4 | console.error('trigger an console.error in render') + | ^ + 5 | return

render

+ 6 | } + 7 |", + } + `) } else { expect(result).toMatchInlineSnapshot(` { "callStacks": "", - "count": 1, - "description": "trigger an console ", - "source": "app/browser/event/page.js (7:17) @ error + "count": ${isReactExperimental ? 1 : 2}, + "description": "trigger an console.error in render", + "source": "app/browser/render/page.js (4:11) @ error - 5 |