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 75cfeb06eb94b..2b46347d4fa2d 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 @@ -1,9 +1,8 @@ import React from 'react' -import { ACTION_UNHANDLED_ERROR, type OverlayState } from '../shared' +import type { OverlayState } from '../shared' import { ShadowPortal } from '../internal/components/ShadowPortal' import { BuildError } from '../internal/container/BuildError' -import { Errors, type SupportedErrorEvent } from '../internal/container/Errors' -import { parseStack } from '../internal/helpers/parse-stack' +import { Errors } from '../internal/container/Errors' import { StaticIndicator } from '../internal/container/StaticIndicator' import { Base } from '../internal/styles/Base' import { ComponentStyles } from '../internal/styles/ComponentStyles' @@ -13,7 +12,7 @@ import type { Dispatcher } from './hot-reloader-client' import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler' interface ReactDevOverlayState { - reactError: SupportedErrorEvent | null + isReactError: boolean } export default class ReactDevOverlay extends React.PureComponent< { @@ -23,27 +22,20 @@ export default class ReactDevOverlay extends React.PureComponent< }, ReactDevOverlayState > { - state = { reactError: null } + state = { isReactError: false } static getDerivedStateFromError(error: Error): ReactDevOverlayState { - if (!error.stack) return { reactError: null } + if (!error.stack) return { isReactError: false } RuntimeErrorHandler.hadRuntimeError = true return { - reactError: { - id: 0, - event: { - type: ACTION_UNHANDLED_ERROR, - reason: error, - frames: parseStack(error.stack), - }, - }, + isReactError: true, } } render() { const { state, children, dispatcher } = this.props - const { reactError } = this.state + const { isReactError } = this.state const hasBuildError = state.buildError != null const hasRuntimeErrors = Boolean(state.errors.length) @@ -52,7 +44,7 @@ export default class ReactDevOverlay extends React.PureComponent< return ( <> - {reactError ? ( + {isReactError ? ( @@ -78,8 +70,10 @@ export default class ReactDevOverlay extends React.PureComponent< {hasRuntimeErrors ? ( { - const stitchedError = getReactStitchedError(err) + // x-ref: https://github.com/facebook/react/pull/28736 + const cause = isError(error) && 'cause' in error ? error.cause : error + const stitchedError = getReactStitchedError(cause) // 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 + if (isBailoutToCSRError(cause)) return reportGlobalError(stitchedError) } diff --git a/test/development/acceptance-app/dynamic-error.test.ts b/test/development/acceptance-app/dynamic-error.test.ts index 1e7e53583598f..258945892e6b4 100644 --- a/test/development/acceptance-app/dynamic-error.test.ts +++ b/test/development/acceptance-app/dynamic-error.test.ts @@ -7,31 +7,30 @@ import { outdent } from 'outdent' describe('dynamic = "error" in devmode', () => { const { next } = nextTestSetup({ files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), - skipStart: true, }) it('should show error overlay when dynamic is forced', async () => { - const { session, cleanup } = await sandbox(next, undefined, '/server') - - // dynamic = "error" and force dynamic - await session.patch( - 'app/server/page.js', - outdent` - import { cookies } from 'next/headers'; - - import Component from '../../index' - - export default async function Page() { - await cookies() - return - } - - export const dynamic = "error" - ` + const { session, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/server/page.js', + outdent` + import { cookies } from 'next/headers'; + + export default async function Page() { + await cookies() + return null + } + + export const dynamic = "error" + `, + ], + ]), + '/server' ) await session.assertHasRedbox() - console.log(await session.getRedboxDescription()) expect(await session.getRedboxDescription()).toMatchInlineSnapshot( `"[ Server ] Error: Route /server with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering"` ) diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/browser/browser-only.js b/test/development/app-dir/owner-stack-invalid-element-type/app/browser/browser-only.js new file mode 100644 index 0000000000000..dc00bde91399b --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/app/browser/browser-only.js @@ -0,0 +1,11 @@ +'use client' + +import Foo from '../foo' + +export default function BrowserOnly() { + return ( +
+ +
+ ) +} diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/browser/page.js b/test/development/app-dir/owner-stack-invalid-element-type/app/browser/page.js new file mode 100644 index 0000000000000..77875a1db1096 --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/app/browser/page.js @@ -0,0 +1,11 @@ +'use client' + +import dynamic from 'next/dynamic' + +const BrowserOnly = dynamic(() => import('./browser-only'), { + ssr: false, +}) + +export default function Page() { + return +} diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/foo.js b/test/development/app-dir/owner-stack-invalid-element-type/app/foo.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/layout.tsx b/test/development/app-dir/owner-stack-invalid-element-type/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/rsc/page.js b/test/development/app-dir/owner-stack-invalid-element-type/app/rsc/page.js new file mode 100644 index 0000000000000..3b835737b3983 --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/app/rsc/page.js @@ -0,0 +1,9 @@ +import Foo from '../foo' + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/development/app-dir/owner-stack-invalid-element-type/app/ssr/page.js b/test/development/app-dir/owner-stack-invalid-element-type/app/ssr/page.js new file mode 100644 index 0000000000000..297ed0e3ef260 --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/app/ssr/page.js @@ -0,0 +1,11 @@ +'use client' + +import Foo from '../foo' + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/development/app-dir/owner-stack-invalid-element-type/next.config.js b/test/development/app-dir/owner-stack-invalid-element-type/next.config.js new file mode 100644 index 0000000000000..d14b1bf8fdc37 --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/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/owner-stack-invalid-element-type/owner-stack-invalid-element-type.test.ts b/test/development/app-dir/owner-stack-invalid-element-type/owner-stack-invalid-element-type.test.ts new file mode 100644 index 0000000000000..0e980f3a2239b --- /dev/null +++ b/test/development/app-dir/owner-stack-invalid-element-type/owner-stack-invalid-element-type.test.ts @@ -0,0 +1,147 @@ +import { nextTestSetup } from 'e2e-utils' +import { assertHasRedbox, getRedboxSource } from 'next-test-utils' + +async function getStackFramesContent(browser) { + const stackFrameElements = await browser.elementsByCss( + '[data-nextjs-call-stack-frame]' + ) + const stackFramesContent = ( + await Promise.all( + stackFrameElements.map(async (frame) => { + const functionNameEl = await frame.$('[data-nextjs-frame-expanded]') + const sourceEl = await frame.$('[data-has-source]') + const functionName = functionNameEl + ? await functionNameEl.innerText() + : '' + const source = sourceEl ? await sourceEl.innerText() : '' + + if (!functionName) { + return '' + } + return `at ${functionName} (${source})` + }) + ) + ) + .filter(Boolean) + .join('\n') + + return stackFramesContent +} + +describe('app-dir - owner-stack-invalid-element-type', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should catch invalid element from a client-only component', async () => { + const browser = await next.browser('/browser') + + await assertHasRedbox(browser) + const source = await getRedboxSource(browser) + + const stackFramesContent = await getStackFramesContent(browser) + if (process.env.TURBOPACK) { + expect(stackFramesContent).toMatchInlineSnapshot( + `"at Page (app/browser/page.js (10:10))"` + ) + expect(source).toMatchInlineSnapshot(` + "app/browser/browser-only.js (8:7) @ BrowserOnly + + 6 | return ( + 7 |
+ > 8 | + | ^ + 9 |
+ 10 | ) + 11 | }" + `) + } else { + expect(stackFramesContent).toMatchInlineSnapshot( + `"at BrowserOnly (app/browser/page.js (10:11))"` + ) + expect(source).toMatchInlineSnapshot(` + "app/browser/browser-only.js (8:8) @ Foo + + 6 | return ( + 7 |
+ > 8 | + | ^ + 9 |
+ 10 | ) + 11 | }" + `) + } + }) + + it('should catch invalid element from a rsc component', async () => { + const browser = await next.browser('/rsc') + + await assertHasRedbox(browser) + const stackFramesContent = await getStackFramesContent(browser) + const source = await getRedboxSource(browser) + + if (process.env.TURBOPACK) { + expect(stackFramesContent).toMatchInlineSnapshot(`""`) + expect(source).toMatchInlineSnapshot(` + "app/rsc/page.js (6:7) @ Page + + 4 | return ( + 5 |
+ > 6 | + | ^ + 7 |
+ 8 | ) + 9 | }" + `) + } else { + expect(stackFramesContent).toMatchInlineSnapshot(`""`) + expect(source).toMatchInlineSnapshot(` + "app/rsc/page.js (6:8) @ Foo + + 4 | return ( + 5 |
+ > 6 | + | ^ + 7 |
+ 8 | ) + 9 | }" + `) + } + }) + + it('should catch invalid element from on ssr client component', async () => { + const browser = await next.browser('/ssr') + + await assertHasRedbox(browser) + + const stackFramesContent = await getStackFramesContent(browser) + const source = await getRedboxSource(browser) + if (process.env.TURBOPACK) { + expect(stackFramesContent).toMatchInlineSnapshot(`""`) + expect(source).toMatchInlineSnapshot(` + "app/ssr/page.js (8:7) @ Page + + 6 | return ( + 7 |
+ > 8 | + | ^ + 9 |
+ 10 | ) + 11 | }" + `) + } else { + expect(stackFramesContent).toMatchInlineSnapshot(`""`) + expect(source).toMatchInlineSnapshot(` + "app/ssr/page.js (8:8) @ Foo + + 6 | return ( + 7 |
+ > 8 | + | ^ + 9 |
+ 10 | ) + 11 | }" + `) + } + }) +})