fix unhandled runtime error when notFound() triggered in generateMetadata w/ parallel routes (#65102)

### What
When a page throws a `notFound()` error in `generateMetadata`, and the
page contains parallel route(s), an unhandled runtime error would be
thrown rather than displaying the not found page.

### Why
We use the `<MetadataOutlet />` component to throw any errors caught
during metadata resolution once the metadata is rendered by React so
that it can be caught by an error boundary, so that it doesn't throw
during metadata resolution. A promise is tracked & resolved with an
error once the metadata tree is rendered. Once the promise resolves, the
outlet component will throw.

However, every `__PAGE__` segment that would be rendered as part of the
page the user is on will render this `<MetadataOutlet />` component. We
only need a single outlet per segment as only a single error needs to be
thrown & caught.

### How
This will only render a `MetadataOutlet` for the first parallel route
that is encountered at each segment depth, as we only need a single
handler to throw the error.

Fixes #65013
Closes NEXT-3222
This commit is contained in:
Zack Tanner 2024-04-29 08:25:57 -07:00 committed by GitHub
parent 15f7418fd6
commit 5c9b06256a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 104 additions and 5 deletions

View file

@ -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({

View file

@ -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,
})

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>@bar slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>@bar slot</div>
}

View file

@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'
export function generateMetadata() {
notFound()
}
export default function Page() {
return <div>@bar slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>@foo slot</div>
}

View file

@ -0,0 +1,9 @@
export function generateMetadata() {
return {
title: 'Create Next App',
}
}
export default function Page() {
return <div>@foo slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>@foo slot</div>
}

View file

@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'
export function generateMetadata() {
notFound()
}
export default function Page() {
return <div>@foobar slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Layout(props: { foobar: React.ReactNode }) {
return <>{props.foobar}</>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Custom Not Found!</div>
}

View file

@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'
export function generateMetadata() {
notFound()
}
export default function Page() {
return <h1>Hello from Page</h1>
}

View file

@ -0,0 +1,9 @@
export function generateMetadata() {
return {
title: 'Create Next App',
}
}
export default function Page() {
return <h1>Hello from Page</h1>
}

View file

@ -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')