Skip to content

Commit

Permalink
fix unhandled runtime error when notFound() triggered in generateMeta…
Browse files Browse the repository at this point in the history
…data 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
  • Loading branch information
ztanner committed Apr 29, 2024
1 parent 15f7418 commit 5c9b062
Show file tree
Hide file tree
Showing 14 changed files with 104 additions and 5 deletions.
7 changes: 4 additions & 3 deletions packages/next/src/lib/metadata/metadata.tsx
Expand Up @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions packages/next/src/server/app-render/create-component-tree.tsx
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down
@@ -0,0 +1,3 @@
export default function Page() {
return <div>@bar slot</div>
}
@@ -0,0 +1,3 @@
export default function Page() {
return <div>@bar slot</div>
}
@@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'

export function generateMetadata() {
notFound()
}

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

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

export function generateMetadata() {
notFound()
}

export default function Page() {
return <div>@foobar slot</div>
}
@@ -0,0 +1,3 @@
export default function Layout(props: { foobar: React.ReactNode }) {
return <>{props.foobar}</>
}
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Custom Not Found!</div>
}
@@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'

export function generateMetadata() {
notFound()
}

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

export default function Page() {
return <h1>Hello from Page</h1>
}
Expand Up @@ -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')
Expand Down

0 comments on commit 5c9b062

Please sign in to comment.