diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index fc9c5723ca9e4..860dbb20a9787 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -561,6 +561,7 @@ pub struct ExperimentalConfig { /// directory. ppr: Option, taint: Option, + react_owner_stack: Option, #[serde(rename = "dynamicIO")] dynamic_io: Option, proxy_timeout: Option, @@ -1148,6 +1149,13 @@ impl NextConfig { Vc::cell(self.experimental.taint.unwrap_or(false)) } + #[turbo_tasks::function] + pub async fn enable_react_owner_stack(self: Vc) -> Result> { + Ok(Vc::cell( + self.await?.experimental.react_owner_stack.unwrap_or(false), + )) + } + #[turbo_tasks::function] pub fn use_swc_css(&self) -> Vc { Vc::cell( diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 724fc5ef5706e..1ac0cf374ee7c 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -116,12 +116,14 @@ pub async fn get_next_client_import_map( match ty.into_value() { ClientContextType::Pages { .. } => {} ClientContextType::App { app_dir } => { - let react_flavor = - if *next_config.enable_ppr().await? || *next_config.enable_taint().await? { - "-experimental" - } else { - "" - }; + let react_flavor = if *next_config.enable_ppr().await? + || *next_config.enable_taint().await? + || *next_config.enable_react_owner_stack().await? + { + "-experimental" + } else { + "" + }; import_map.insert_exact_alias( "react", @@ -683,7 +685,12 @@ async fn rsc_aliases( ) -> Result<()> { let ppr = *next_config.enable_ppr().await?; let taint = *next_config.enable_taint().await?; - let react_channel = if ppr || taint { "-experimental" } else { "" }; + let react_owner_stack = *next_config.enable_react_owner_stack().await?; + let react_channel = if ppr || taint || react_owner_stack { + "-experimental" + } else { + "" + }; let react_client_package = get_react_client_package(&next_config).await?; let mut alias = FxIndexMap::default(); diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index e12c205c1f28a..20ad88f8f6f14 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -185,6 +185,9 @@ export function getDefineEnv({ ), 'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr), 'process.env.__NEXT_DYNAMIC_IO': !!config.experimental.dynamicIO, + 'process.env.__NEXT_REACT_OWNER_STACK': Boolean( + config.experimental.reactOwnerStack + ), 'process.env.__NEXT_AFTER': config.experimental.after ?? false, 'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false, 'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': fetchCacheKeyPrefix ?? '', diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index 3c059896a6fe5..c39d36c01316a 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -9,7 +9,11 @@ import React, { use } from 'react' // eslint-disable-next-line import/no-extraneous-dependencies import { createFromReadableStream } from 'react-server-dom-webpack/client' import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime' -import { onRecoverableError } from './on-recoverable-error' +import { onRecoverableError } from './react-client-callbacks/shared' +import { + onCaughtError, + onUncaughtError, +} from './react-client-callbacks/app-router' import { callServer } from './app-call-server' import { findSourceMapURL } from './app-find-source-map-url' import { @@ -23,6 +27,8 @@ import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runt /// +const isReactOwnerStackEnabled = !!process.env.__NEXT_REACT_OWNER_STACK + const appElement: HTMLElement | Document | null = document const encoder = new TextEncoder() @@ -227,8 +233,17 @@ export function hydrate() { const rootLayoutMissingTags = window.__next_root_layout_missing_tags const hasMissingTags = !!rootLayoutMissingTags?.length + const errorCallbacks = + isReactOwnerStackEnabled && process.env.NODE_ENV !== 'production' + ? { + onCaughtError, + onUncaughtError, + } + : undefined + const options = { onRecoverableError, + ...errorCallbacks, } satisfies ReactDOMClient.RootOptions const isError = document.documentElement.id === '__next_error__' || hasMissingTags 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 3ba28d9eec2a6..afa967f1d782d 100644 --- a/packages/next/src/client/components/globals/intercept-console-error.ts +++ b/packages/next/src/client/components/globals/intercept-console-error.ts @@ -1,6 +1,8 @@ import { isNextRouterError } from '../is-next-router-error' import { handleClientError } from '../react-dev-overlay/internal/helpers/use-error-handler' +export const originConsoleError = window.console.error + // Patch console.error to collect information about hydration errors export function patchConsoleError() { // Ensure it's only patched once @@ -8,10 +10,10 @@ export function patchConsoleError() { return } - const originConsoleError = window.console.error window.console.error = (...args) => { // See https://github.com/facebook/react/blob/d50323eb845c5fde0d720cae888bf35dedd05506/packages/react-reconciler/src/ReactFiberErrorLogger.js#L78 - const error = process.env.NODE_ENV !== 'production' ? args[1] : args[0] + const errorIndex = process.env.NODE_ENV !== 'production' ? 1 : 0 + const error = args[errorIndex] if (!isNextRouterError(error)) { if (process.env.NODE_ENV !== 'production') { diff --git a/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx index fd32ad6cfdd8c..dca89098a7260 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx @@ -12,6 +12,7 @@ import { ComponentStyles } from '../internal/styles/ComponentStyles' import { CssReset } from '../internal/styles/CssReset' import { RootLayoutMissingTagsError } from '../internal/container/root-layout-missing-tags-error' import type { Dispatcher } from './hot-reloader-client' +import { getReactStitchedError } from '../internal/helpers/stitched-error' interface ReactDevOverlayState { reactError: SupportedErrorEvent | null @@ -27,15 +28,17 @@ export default class ReactDevOverlay extends React.PureComponent< > { state = { reactError: null } - static getDerivedStateFromError(error: Error): ReactDevOverlayState { - if (!error.stack) return { reactError: null } + static getDerivedStateFromError(err: Error): ReactDevOverlayState { + if (!err.stack) return { reactError: null } + + const error = getReactStitchedError(err) return { reactError: { id: 0, event: { type: ACTION_UNHANDLED_ERROR, reason: error, - frames: parseStack(error.stack), + frames: parseStack(error.stack || ''), }, }, } diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts index b671fe48902bc..7d674c8f146a6 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/parse-component-stack.ts @@ -72,6 +72,7 @@ export function parseComponentStack( ): ComponentStackFrame[] { const componentStackFrames: ComponentStackFrame[] = [] for (const line of componentStack.trim().split('\n')) { + // TODO: support safari stack trace // Get component and file from the component stack line const match = /at ([^ ]+)( \((.*)\))?/.exec(line) if (match?.[1]) { 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 new file mode 100644 index 0000000000000..f8272c7122f46 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/stitched-error.ts @@ -0,0 +1,43 @@ +import React from 'react' + +const captureOwnerStack = process.env.__NEXT_REACT_OWNER_STACK + ? (React as any).captureOwnerStack + : () => '' + +const REACT_ERROR_STACK_BOTTOM_FRAME = 'react-stack-bottom-frame' +const REACT_ERROR_STACK_BOTTOM_FRAME_REGEX = new RegExp( + `(at ${REACT_ERROR_STACK_BOTTOM_FRAME} )|(${REACT_ERROR_STACK_BOTTOM_FRAME}\\@)` +) + +export function getReactStitchedError(err: T): Error { + if (!process.env.__NEXT_REACT_OWNER_STACK) { + return err as any + } + + const isErrorInstance = err instanceof Error + const originStack = isErrorInstance ? err.stack || '' : '' + const originMessage = isErrorInstance ? err.message : '' + const stackLines = originStack.split('\n') + const indexOfSplit = stackLines.findIndex((line) => + REACT_ERROR_STACK_BOTTOM_FRAME_REGEX.test(line) + ) + const isOriginalReactError = indexOfSplit >= 0 // has the react-stack-bottom-frame + let newStack = isOriginalReactError + ? stackLines.slice(0, indexOfSplit).join('\n') + : originStack + + const newError = new Error(originMessage) + // Copy all enumerable properties, e.g. digest + Object.assign(newError, err) + newError.stack = newStack + + // Avoid duplicate overriding stack frames + const ownerStack = captureOwnerStack() + if (ownerStack && newStack.endsWith(ownerStack) === false) { + newStack += ownerStack + // Override stack + newError.stack = newStack + } + + return newError +} diff --git a/packages/next/src/client/index.tsx b/packages/next/src/client/index.tsx index 405b5c936f134..b6ad90c2faeaf 100644 --- a/packages/next/src/client/index.tsx +++ b/packages/next/src/client/index.tsx @@ -1,7 +1,6 @@ /* global location */ // imports polyfill from `@next/polyfill-module` after build. import '../build/polyfills/polyfill-module' - import type Router from '../shared/lib/router/router' import type { AppComponent, @@ -46,7 +45,7 @@ import { SearchParamsContext, PathParamsContext, } from '../shared/lib/hooks-client-context.shared-runtime' -import { onRecoverableError } from './on-recoverable-error' +import { onRecoverableError } from './react-client-callbacks/shared' import tracer from './tracing/tracer' import reportToSocket from './tracing/report-to-socket' diff --git a/packages/next/src/client/on-recoverable-error.ts b/packages/next/src/client/on-recoverable-error.ts deleted file mode 100644 index c2273e0fe01da..0000000000000 --- a/packages/next/src/client/on-recoverable-error.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr' - -// x-ref: https://github.com/facebook/react/blob/d4bc16a7d69eb2ea38a88c8ac0b461d5f72cdcab/packages/react-dom/src/client/ReactDOMRoot.js#L83 -const defaultOnRecoverableError = - typeof reportError === 'function' - ? // In modern browsers, reportError will dispatch an error event, - // emulating an uncaught JavaScript error. - reportError - : (error: any) => { - window.console.error(error) - } - -export function onRecoverableError( - err: unknown, - errorInfo: { componentStack?: string } -) { - // In development mode, pass along the component stack to the error - if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) { - ;(err as any)._componentStack = errorInfo.componentStack - } - // Using default react onRecoverableError - - // Skip certain custom errors which are not expected to be reported on client - if (isBailoutToCSRError(err)) return - - defaultOnRecoverableError(err) -} diff --git a/packages/next/src/client/react-client-callbacks/app-router.ts b/packages/next/src/client/react-client-callbacks/app-router.ts new file mode 100644 index 0000000000000..c878cfb0625ee --- /dev/null +++ b/packages/next/src/client/react-client-callbacks/app-router.ts @@ -0,0 +1,85 @@ +// This file is only used in app router due to the specific error state handling. + +import type { HydrationOptions } from 'react-dom/client' +import { getReactStitchedError } from '../components/react-dev-overlay/internal/helpers/stitched-error' +import { handleClientError } from '../components/react-dev-overlay/internal/helpers/use-error-handler' +import { isNextRouterError } from '../components/is-next-router-error' +import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' +import { reportGlobalError } from './report-global-error' +import isError from '../../lib/is-error' +import { originConsoleError } from '../components/globals/intercept-console-error' + +export const onCaughtError: HydrationOptions['onCaughtError'] = ( + err, + errorInfo +) => { + // Skip certain custom errors which are not expected to be reported on client + if (isBailoutToCSRError(err) || isNextRouterError(err)) return + + const stitchedError = getReactStitchedError(err) + + if (process.env.NODE_ENV === 'development') { + const errorBoundaryComponent = errorInfo?.errorBoundary?.constructor + const errorBoundaryName = + // read react component displayName + (errorBoundaryComponent as any)?.displayName || + errorBoundaryComponent?.name || + 'Unknown' + + const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1] + + // Match chrome or safari stack trace + const matches = + componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? [] + const componentThatErroredName = matches[1] || matches[2] || 'Unknown' + + // In development mode, pass along the component stack to the error + if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) { + ;(stitchedError as any)._componentStack = errorInfo.componentStack + } + + // Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler. + const errorLocation = `The above error occurred in the <${componentThatErroredName}> component. It was handled by the <${errorBoundaryName}> error boundary.` + + const originErrorStack = isError(err) ? err.stack || '' : '' + + // Always log the modified error instance so the console.error interception side can pick it up easily without constructing an error again. + originConsoleError(originErrorStack + '\n\n' + errorLocation) + handleClientError(stitchedError) + } else { + originConsoleError(err) + } +} + +export const onUncaughtError: HydrationOptions['onUncaughtError'] = ( + err, + errorInfo +) => { + // Skip certain custom errors which are not expected to be reported on client + if (isBailoutToCSRError(err) || isNextRouterError(err)) return + + const stitchedError = getReactStitchedError(err) + + if (process.env.NODE_ENV === 'development') { + const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1] + + // Match chrome or safari stack trace + const matches = + componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? [] + const componentThatErroredName = matches[1] || matches[2] || 'Unknown' + + // In development mode, pass along the component stack to the error + if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) { + ;(stitchedError as any)._componentStack = errorInfo.componentStack + } + + // Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler. + const errorLocation = `The above error occurred in the <${componentThatErroredName}> component.` + + originConsoleError(stitchedError.stack + '\n\n' + errorLocation) + // Always log the modified error instance so the console.error interception side can pick it up easily without constructing an error again. + reportGlobalError(stitchedError) + } else { + reportGlobalError(err) + } +} diff --git a/packages/next/src/client/react-client-callbacks/report-global-error.ts b/packages/next/src/client/react-client-callbacks/report-global-error.ts new file mode 100644 index 0000000000000..6b51d21a09c1f --- /dev/null +++ b/packages/next/src/client/react-client-callbacks/report-global-error.ts @@ -0,0 +1,8 @@ +export const reportGlobalError = + typeof reportError === 'function' + ? // In modern browsers, reportError will dispatch an error event, + // emulating an uncaught JavaScript error. + reportError + : (error: any) => { + window.console.error(error) + } diff --git a/packages/next/src/client/react-client-callbacks/shared.ts b/packages/next/src/client/react-client-callbacks/shared.ts new file mode 100644 index 0000000000000..5b0bc5bf8730b --- /dev/null +++ b/packages/next/src/client/react-client-callbacks/shared.ts @@ -0,0 +1,20 @@ +// This module can be shared between both pages router and app router + +import type { HydrationOptions } from 'react-dom/client' +import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' +import { reportGlobalError } from './report-global-error' + +export const onRecoverableError: HydrationOptions['onRecoverableError'] = ( + err, + errorInfo +) => { + const stitchedError = err // getReactStitchedError(err) + // In development mode, pass along the component stack to the error + if (process.env.NODE_ENV === 'development' && errorInfo.componentStack) { + ;(stitchedError as any)._componentStack = errorInfo.componentStack + } + // Skip certain custom errors which are not expected to be reported on client + if (isBailoutToCSRError(err)) return + + reportGlobalError(stitchedError) +} diff --git a/packages/next/src/lib/needs-experimental-react.ts b/packages/next/src/lib/needs-experimental-react.ts index 373d4d61b146e..7e617aa778044 100644 --- a/packages/next/src/lib/needs-experimental-react.ts +++ b/packages/next/src/lib/needs-experimental-react.ts @@ -1,5 +1,6 @@ import type { NextConfig } from '../server/config-shared' export function needsExperimentalReact(config: NextConfig) { - return Boolean(config.experimental?.ppr || config.experimental?.taint) + const { ppr, taint, reactOwnerStack } = config.experimental || {} + return Boolean(ppr || taint || reactOwnerStack) } diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 47c062b1cdd5e..e751575eabbe4 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -332,6 +332,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), pprFallbacks: z.boolean().optional(), taint: z.boolean().optional(), + reactOwnerStack: z.boolean().optional(), prerenderEarlyExit: z.boolean().optional(), proxyTimeout: z.number().gte(0).optional(), scrollRestoration: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 2e4987e19c805..fd3614b9c4536 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -462,6 +462,12 @@ export interface ExperimentalConfig { */ taint?: boolean + /** + * Enables leveraging experimental captureOwnerStack API in React, + * to create a better stack trace for React errors. + */ + reactOwnerStack?: boolean + serverActions?: { /** * Allows adjusting body parser size limit for server actions. @@ -1135,6 +1141,7 @@ export const defaultConfig: NextConfig = { process.env.__NEXT_TEST_MODE && process.env.__NEXT_EXPERIMENTAL_PPR === 'true' ), + reactOwnerStack: false, webpackBuildWorker: undefined, webpackMemoryOptimizations: false, optimizeServerReact: true, diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts index 9a22ed84419a2..aa3c44d965224 100644 --- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts @@ -4,7 +4,6 @@ import { FileRef, nextTestSetup } from 'e2e-utils' import { check, describeVariants as describe, - expandCallStack, getRedboxCallStackCollapsed, retry, } from 'next-test-utils' @@ -777,7 +776,7 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox app %s', () => { await cleanup() }) - test('Call stack count for client error', async () => { + test('Call stack for client error', async () => { const { session, browser, cleanup } = await sandbox( next, new Map([ @@ -796,25 +795,30 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox app %s', () => { ]) ) - await session.assertHasRedbox() - - await expandCallStack(browser) - - // Expect more than the default amount of frames - // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements - const callStackFrames = await browser.elementsByCss( - '[data-nextjs-call-stack-frame]' - ) + try { + await session.assertHasRedbox() - expect(callStackFrames.length).toBeGreaterThan(9) + // Should still show the errored line in source code + const source = await session.getRedboxSource() + expect(source).toContain('app/page.js') + expect(source).toContain(`throw new Error('Client error')`) - const moduleGroup = await browser.elementsByCss( - '[data-nextjs-collapsed-call-stack-details]' - ) - // Expect some of the call stack frames to be grouped (by React or Next.js) - expect(moduleGroup.length).toBeGreaterThan(0) + await expect( + browser.hasElementByCssSelector( + '[data-nextjs-data-runtime-error-collapsed-action]' + ) + ).resolves.toEqual(false) - await cleanup() + const stackFrameElements = await browser.elementsByCss( + '[data-nextjs-call-stack-frame]' + ) + const stackFrames = ( + await Promise.all(stackFrameElements.map((f) => f.innerText())) + ).filter(Boolean) + expect(stackFrames).toEqual([]) + } finally { + await cleanup() + } }) test('Call stack for server error', async () => { @@ -849,9 +853,9 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox app %s', () => { const stackFrameElements = await browser.elementsByCss( '[data-nextjs-call-stack-frame]' ) - const stackFrames = await Promise.all( - stackFrameElements.map((f) => f.innerText()) - ) + const stackFrames = ( + await Promise.all(stackFrameElements.map((f) => f.innerText())) + ).filter(Boolean) expect(stackFrames).toEqual([]) } finally { await cleanup() diff --git a/test/development/acceptance/ReactRefreshLogBox.test.ts b/test/development/acceptance/ReactRefreshLogBox.test.ts index e4c3123679561..ce0a5ed73b524 100644 --- a/test/development/acceptance/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance/ReactRefreshLogBox.test.ts @@ -1,7 +1,7 @@ /* eslint-env jest */ import { sandbox } from 'development-sandbox' import { FileRef, nextTestSetup } from 'e2e-utils' -import { describeVariants as describe, expandCallStack } from 'next-test-utils' +import { describeVariants as describe } from 'next-test-utils' import path from 'path' import { outdent } from 'outdent' @@ -765,8 +765,6 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox %s', () => { await session.assertHasRedbox() - await expandCallStack(browser) - // Expect more than the default amount of frames // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements const callStackFrames = await browser.elementsByCss( @@ -805,7 +803,6 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox %s', () => { ]) ) await session.assertHasRedbox() - await expandCallStack(browser) let callStackFrames = await browser.elementsByCss( '[data-nextjs-call-stack-frame]' ) @@ -835,7 +832,6 @@ describe.each(['default', 'turbo'])('ReactRefreshLogBox %s', () => { ]) ) await session.assertHasRedbox() - await expandCallStack(browser) // Should still show the errored line in source code const source = await session.getRedboxSource() diff --git a/test/development/app-dir/dynamic-error-trace/index.test.ts b/test/development/app-dir/dynamic-error-trace/index.test.ts index d95bb593d65ab..7208f5dcc521f 100644 --- a/test/development/app-dir/dynamic-error-trace/index.test.ts +++ b/test/development/app-dir/dynamic-error-trace/index.test.ts @@ -1,29 +1,16 @@ import { nextTestSetup } from 'e2e-utils' -import { - assertHasRedbox, - shouldRunTurboDevTest, - getRedboxSource, -} from 'next-test-utils' +import { assertHasRedbox, getRedboxSource } from 'next-test-utils' import { outdent } from 'outdent' const isReactExperimental = process.env.__NEXT_EXPERIMENTAL_PPR === 'true' +function normalizeStackTrace(trace) { + return trace.replace(/ \(.*\)/g, '') +} + describe('app dir - dynamic error trace', () => { const { next, skipped } = nextTestSetup({ files: __dirname, - dependencies: { - swr: 'latest', - }, - packageJson: { - scripts: { - build: 'next build', - dev: `next ${shouldRunTurboDevTest() ? 'dev --turbo' : 'dev'}`, - start: 'next start', - }, - }, - installCommand: 'pnpm install', - startCommand: (global as any).isNextDev ? 'pnpm dev' : 'pnpm start', - buildCommand: 'pnpm build', skipDeployment: true, }) if (skipped) return @@ -42,22 +29,15 @@ describe('app dir - dynamic error trace', () => { const stackFrameElements = await browser.elementsByCss( '[data-nextjs-call-stack-frame]' ) - const stackFrames = await Promise.all( - // TODO: Why is this text empty? - stackFrameElements.map((f) => f.innerText()) - ) - expect(stackFrames).toEqual( - // TODO: Show useful stack - isReactExperimental - ? [ - // Internal frames of React. - // Feel free to adjust until we show useful stacks. - '', - '', - '', - '', - ] - : [] + const stackFramesContent = // TODO: Why is this text empty? + (await Promise.all(stackFrameElements.map((f) => f.innerText()))) + // Filter out the frames having code snippet but without methodName and source + .filter(Boolean) + .join('\n') + + // TODO: Show useful stack + expect(normalizeStackTrace(stackFramesContent)).toMatchInlineSnapshot( + isReactExperimental ? `""` : `""` ) const codeframe = await getRedboxSource(browser) diff --git a/test/development/app-dir/stitching-errors/app/browser/caught/page.js b/test/development/app-dir/stitching-errors/app/browser/caught/page.js new file mode 100644 index 0000000000000..4d1f6fea4c41f --- /dev/null +++ b/test/development/app-dir/stitching-errors/app/browser/caught/page.js @@ -0,0 +1,44 @@ +'use client' + +import { Component } from 'react' + +class MyErrorBoundary extends Component { + static getDerivedStateFromError(error) { + return { error } + } + + state = { error: null } + + render() { + if (this.state.error) { + return 'failed' + } + return this.props.children + } +} + +function Inner() { + return ( + + + + ) +} + +function Thrower() { + useErrorHook() +} + +function useThrowError() { + if (typeof window !== 'undefined') { + throw new Error('browser error') + } +} + +function useErrorHook() { + useThrowError() +} + +export default function Page() { + return +} diff --git a/test/development/app-dir/stitching-errors/app/browser/uncaught/page.js b/test/development/app-dir/stitching-errors/app/browser/uncaught/page.js new file mode 100644 index 0000000000000..68fd414429de6 --- /dev/null +++ b/test/development/app-dir/stitching-errors/app/browser/uncaught/page.js @@ -0,0 +1,16 @@ +'use client' + +function useThrowError() { + if (typeof window !== 'undefined') { + throw new Error('browser error') + } +} + +function useErrorHook() { + useThrowError() +} + +export default function Page() { + useErrorHook() + return

hello world

+} diff --git a/test/development/app-dir/stitching-errors/app/layout.js b/test/development/app-dir/stitching-errors/app/layout.js new file mode 100644 index 0000000000000..a3a86a5ca1e12 --- /dev/null +++ b/test/development/app-dir/stitching-errors/app/layout.js @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/development/app-dir/stitching-errors/app/ssr/page.js b/test/development/app-dir/stitching-errors/app/ssr/page.js new file mode 100644 index 0000000000000..73a83f860b7cc --- /dev/null +++ b/test/development/app-dir/stitching-errors/app/ssr/page.js @@ -0,0 +1,14 @@ +'use client' + +function useThrowError() { + throw new Error('ssr error') +} + +function useErrorHook() { + useThrowError() +} + +export default function Page() { + useErrorHook() + return

hello world

+} diff --git a/test/development/app-dir/stitching-errors/next.config.js b/test/development/app-dir/stitching-errors/next.config.js new file mode 100644 index 0000000000000..d14b1bf8fdc37 --- /dev/null +++ b/test/development/app-dir/stitching-errors/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + reactOwnerStack: true, + }, +} + +module.exports = nextConfig diff --git a/test/development/app-dir/stitching-errors/stitching-errors.test.ts b/test/development/app-dir/stitching-errors/stitching-errors.test.ts new file mode 100644 index 0000000000000..5397ff49a8f93 --- /dev/null +++ b/test/development/app-dir/stitching-errors/stitching-errors.test.ts @@ -0,0 +1,194 @@ +import { nextTestSetup } from 'e2e-utils' +import { assertHasRedbox, assertNoRedbox } from 'next-test-utils' + +// Remove the location `()` part in every line of stack trace; +// Remove the leading spaces in every line of stack trace; +// Remove the trailing spaces in every line of stack trace; +function normalizeStackTrace(trace: string) { + return trace + .replace(/\(.*\)/g, '') + .replace(/^\s+/gm, '') + .trim() +} + +async function getStackFramesContent(browser) { + const stackFrameElements = await browser.elementsByCss( + '[data-nextjs-call-stack-frame]' + ) + const stackFramesContent = ( + await Promise.all(stackFrameElements.map((f) => f.innerText())) + ) + .filter(Boolean) + .join('\n') + + return normalizeStackTrace(stackFramesContent) +} + +// Remove leading spaces in every line of stack trace + +describe('stitching errors', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should log stitched error for browser uncaught errors', async () => { + const browser = await next.browser('/browser/uncaught') + + await assertHasRedbox(browser) + + const stackFramesContent = await getStackFramesContent(browser) + if (process.env.TURBOPACK) { + expect(stackFramesContent).toMatchInlineSnapshot(` + "useErrorHook + app/browser/uncaught/page.js + Page + app/browser/uncaught/page.js" + `) + } else { + expect(stackFramesContent).toMatchInlineSnapshot(` + "useThrowError + app/browser/uncaught/page.js + useErrorHook + app/browser/uncaught/page.js + ReactDevOverlay + ../src/client/components/react-dev-overlay/app/hot-reloader-client.tsx + assetPrefix + ../src/client/components/app-router.tsx + actionQueue + ../src/client/components/app-router.tsx + AppRouter + ../src/client/app-index.tsx" + `) + } + + const logs = await browser.log() + const errorLog = logs.find((log) => { + return log.message.includes('Error: browser error') + }).message + + if (process.env.TURBOPACK) { + expect(normalizeStackTrace(errorLog)).toMatchInlineSnapshot(` + "Error: browser error + at useThrowError + at useErrorHook + at Page + at react-stack-bottom-frame + at renderWithHooks + at updateFunctionComponent + at beginWork + at runWithFiberInDEV + at performUnitOfWork + at workLoopSync + at renderRootSync + at performWorkOnRoot + at performWorkOnRootViaSchedulerTask + at MessagePort.performWorkUntilDeadline + The above error occurred in the component. It was handled by the error boundary." + `) + } else { + expect(normalizeStackTrace(errorLog)).toMatchInlineSnapshot(` + "Error: browser error + at useThrowError + at useErrorHook + at Page + at react-stack-bottom-frame + at renderWithHooks + at updateFunctionComponent + at beginWork + at runWithFiberInDEV + at performUnitOfWork + at workLoopSync + at renderRootSync + at performWorkOnRoot + at performWorkOnRootViaSchedulerTask + at MessagePort.performWorkUntilDeadline + The above error occurred in the component. It was handled by the error boundary." + `) + } + }) + + it('should log stitched error for browser caught errors', async () => { + const browser = await next.browser('/browser/caught') + + await assertNoRedbox(browser) + + const logs = await browser.log() + const errorLog = logs.find((log) => { + return log.message.includes('Error: browser error') + }).message + + expect(normalizeStackTrace(errorLog)).toMatchInlineSnapshot(` + "Error: browser error + at useThrowError + at useErrorHook + at Thrower + at react-stack-bottom-frame + at renderWithHooks + at updateFunctionComponent + at beginWork + at runWithFiberInDEV + at performUnitOfWork + at workLoopSync + at renderRootSync + at performWorkOnRoot + at performWorkOnRootViaSchedulerTask + at MessagePort.performWorkUntilDeadline + The above error occurred in the component. It was handled by the error boundary." + `) + }) + + it('should log stitched error for SSR errors', async () => { + const browser = await next.browser('/ssr') + + await assertHasRedbox(browser) + + const stackFramesContent = await getStackFramesContent(browser) + if (process.env.TURBOPACK) { + expect(stackFramesContent).toMatchInlineSnapshot(` + "useErrorHook + app/ssr/page.js + Page + app/ssr/page.js" + `) + } else { + expect(stackFramesContent).toMatchInlineSnapshot(` + "useThrowError + app/ssr/page.js + useErrorHook + app/ssr/page.js + ReactDevOverlay + ../src/client/components/react-dev-overlay/app/hot-reloader-client.tsx + assetPrefix + ../src/client/components/app-router.tsx + actionQueue + ../src/client/components/app-router.tsx + AppRouter + ../src/client/app-index.tsx" + `) + } + + const logs = await browser.log() + const errorLog = logs.find((log) => { + return log.message.includes('Error: ssr error') + }).message + + expect(normalizeStackTrace(errorLog)).toMatchInlineSnapshot(` + "Error: ssr error + at useThrowError + at useErrorHook + at Page + at react-stack-bottom-frame + at renderWithHooks + at updateFunctionComponent + at beginWork + at runWithFiberInDEV + at performUnitOfWork + at workLoopSync + at renderRootSync + at performWorkOnRoot + at performWorkOnRootViaSchedulerTask + at MessagePort.performWorkUntilDeadline + The above error occurred in the component. It was handled by the error boundary." + `) + }) +}) diff --git a/test/development/app-dir/stitching-errors/tsconfig.json b/test/development/app-dir/stitching-errors/tsconfig.json new file mode 100644 index 0000000000000..1d4f624eff7d9 --- /dev/null +++ b/test/development/app-dir/stitching-errors/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/test/lib/next-test-utils.ts b/test/lib/next-test-utils.ts index 73a8aa5127aa5..f9805f90e90b6 100644 --- a/test/lib/next-test-utils.ts +++ b/test/lib/next-test-utils.ts @@ -1232,15 +1232,6 @@ export async function toggleCollapseComponentStack( .click() } -export async function expandCallStack( - browser: BrowserInterface -): Promise { - // Open full Call Stack - await browser - .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]') - .click() -} - export async function getRedboxCallStack( browser: BrowserInterface ): Promise {