diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 1be2eb2e2071..35be61e61b74 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -96,9 +96,10 @@ export function createMetadataComponents({ resolve(undefined) } else { error = resolvedError - // If the error triggers in initial metadata resolving, re-resolve with proper error type. - // They'll be saved for flight data, when hydrates, it will replaces the SSR'd metadata with this. - // for not-found error: resolve not-found metadata + // If a not-found error is triggered during metadata resolution, we want to capture the metadata + // for the not-found route instead of whatever triggered the error. For all error types, we resolve an + // error, which will cause the outlet to throw it so it'll be handled by an error boundary + // (either an actual error, or an internal error that renders UI such as the NotFoundBoundary). if (!errorType && isNotFoundError(resolvedError)) { const [notFoundMetadataError, notFoundMetadata, notFoundViewport] = await resolveMetadata({ diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 0ea17745987d..37dcc6326115 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -364,7 +364,8 @@ async function createComponentTreeInternal({ const parallelRouteMap = await Promise.all( Object.keys(parallelRoutes).map( async ( - parallelRouteKey + parallelRouteKey, + parallelRouteIndex ): Promise<[string, React.ReactNode, CacheNodeSeedData | null]> => { const isChildrenRouteKey = parallelRouteKey === 'children' const currentSegmentPath: FlightSegmentPath = firstItem @@ -443,7 +444,11 @@ async function createComponentTreeInternal({ injectedJS: injectedJSWithCurrentLayout, injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, asNotFound, - metadataOutlet, + // The metadataOutlet is responsible for throwing any errors that were caught during metadata resolution. + // We only want to render an outlet once per segment, as otherwise the error will be triggered + // multiple times causing an uncaught error. + metadataOutlet: + parallelRouteIndex === 0 ? metadataOutlet : undefined, ctx, missingSlots, }) diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/no-page/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/no-page/page.tsx new file mode 100644 index 000000000000..e3f0dd632d58 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/no-page/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
@bar slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/page-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/page-error/page.tsx new file mode 100644 index 000000000000..e3f0dd632d58 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/page-error/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
@bar slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/slot-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/slot-error/page.tsx new file mode 100644 index 000000000000..9de1854da00c --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@bar/not-found-metadata/slot-error/page.tsx @@ -0,0 +1,9 @@ +import { notFound } from 'next/navigation' + +export function generateMetadata() { + notFound() +} + +export default function Page() { + return
@bar slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/no-page/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/no-page/page.tsx new file mode 100644 index 000000000000..bb86c7ceebd8 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/no-page/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
@foo slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/page-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/page-error/page.tsx new file mode 100644 index 000000000000..9d7c3eb67877 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/page-error/page.tsx @@ -0,0 +1,9 @@ +export function generateMetadata() { + return { + title: 'Create Next App', + } +} + +export default function Page() { + return
@foo slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/slot-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/slot-error/page.tsx new file mode 100644 index 000000000000..bb86c7ceebd8 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@foo/not-found-metadata/slot-error/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
@foo slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/page.tsx new file mode 100644 index 000000000000..b4f79715e6aa --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/page.tsx @@ -0,0 +1,9 @@ +import { notFound } from 'next/navigation' + +export function generateMetadata() { + notFound() +} + +export default function Page() { + return
@foobar slot
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/layout.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/layout.tsx new file mode 100644 index 000000000000..3729d5322eae --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout(props: { foobar: React.ReactNode }) { + return <>{props.foobar} +} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/not-found.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/not-found.tsx new file mode 100644 index 000000000000..465faa0423f3 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/not-found.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Custom Not Found!
+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/page-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/page-error/page.tsx new file mode 100644 index 000000000000..cfe8367402a3 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/page-error/page.tsx @@ -0,0 +1,9 @@ +import { notFound } from 'next/navigation' + +export function generateMetadata() { + notFound() +} + +export default function Page() { + return

Hello from Page

+} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/slot-error/page.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/slot-error/page.tsx new file mode 100644 index 000000000000..d72946b18453 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/slot-error/page.tsx @@ -0,0 +1,9 @@ +export function generateMetadata() { + return { + title: 'Create Next App', + } +} + +export default function Page() { + return

Hello from Page

+} diff --git a/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts b/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts index 2bdf6da4ed8b..ca6454d2979c 100644 --- a/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts +++ b/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts @@ -57,6 +57,36 @@ describe('parallel-route-not-found', () => { expect(warnings.length).toBe(0) }) + it('should handle `notFound()` in generateMetadata on a page that also renders a parallel route', async () => { + const browser = await next.browser('/not-found-metadata/page-error') + + // The page's `generateMetadata` function threw a `notFound()` error, + // so we should see the not found page. + expect(await browser.elementByCss('body').text()).toMatch( + /This page could not be found/ + ) + }) + + it('should handle `notFound()` in a slot', async () => { + const browser = await next.browser('/not-found-metadata/slot-error') + + // The page's `generateMetadata` function threw a `notFound()` error, + // so we should see the not found page. + expect(await browser.elementByCss('body').text()).toMatch( + /This page could not be found/ + ) + }) + + it('should handle `notFound()` in a slot with no `children` slot', async () => { + const browser = await next.browser('/not-found-metadata/no-page') + + // The page's `generateMetadata` function threw a `notFound()` error, + // so we should see the not found page. + expect(await browser.elementByCss('body').text()).toMatch( + /This page could not be found/ + ) + }) + if (isNextDev) { it('should not log any warnings for a regular not found page', async () => { const browser = await next.browser('/this-page-doesnt-exist')