Skip to content

Commit

Permalink
Support metadata exports for server components not-found (#52678)
Browse files Browse the repository at this point in the history
### What?

Support metadata exports for `not-found.js` conventions

### Why?

We want to define metadata such as title or description basic properties for error pages, including 404 and 500 which referrs to `error.js` and `not-found.js` convention. See more requests in #45620 

Did some research around metadata support for not-found and error convention. It's possible to support in `not-found.js` when it's server components as currently metadata is only available but for `error.js` it has to be client components for now so it's hard to support it for now as it's a boundary.

### How?

We determine the convention if we're going to render is page or `not-found` boundary then we traverse the loader tree based on the convention type. One special case is for redirection the temporary metadata is not generated yet, we leave it as default now.

Fixes #52636
  • Loading branch information
huozhi authored Jul 14, 2023
1 parent c3ca17c commit a28ae63
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 86 deletions.
1 change: 1 addition & 0 deletions packages/next/src/client/on-recoverable-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export default function onRecoverableError(err: any) {

// Skip certain custom errors which are not expected to be reported on client
if (err.digest === NEXT_DYNAMIC_NO_SSR_CODE) return

defaultOnRecoverableError(err)
}
16 changes: 15 additions & 1 deletion packages/next/src/lib/metadata/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import { IconsMetadata } from './generate/icons'
import { accumulateMetadata, resolveMetadata } from './resolve-metadata'
import { MetaFilter } from './generate/meta'
import { ResolvedMetadata } from './types/metadata-interface'
import { createDefaultMetadata } from './default-metadata'

// Generate the actual React elements from the resolved metadata.
export async function MetadataTree({
Expand All @@ -26,24 +28,36 @@ export async function MetadataTree({
searchParams,
getDynamicParamFromSegment,
appUsingSizeAdjust,
errorType,
}: {
tree: LoaderTree
pathname: string
searchParams: { [key: string]: any }
getDynamicParamFromSegment: GetDynamicParamFromSegment
appUsingSizeAdjust: boolean
errorType?: 'not-found' | 'redirect'
}) {
const metadataContext = {
pathname,
}

const resolvedMetadata = await resolveMetadata({
tree,
parentParams: {},
metadataItems: [],
searchParams,
getDynamicParamFromSegment,
errorConvention: errorType === 'redirect' ? undefined : errorType,
})
const metadata = await accumulateMetadata(resolvedMetadata, metadataContext)
let metadata: ResolvedMetadata | undefined = undefined

const defaultMetadata = createDefaultMetadata()
// Skip for redirect case as for the temporary redirect case we don't need the metadata on client
if (errorType === 'redirect') {
metadata = defaultMetadata
} else {
metadata = await accumulateMetadata(resolvedMetadata, metadataContext)
}

const elements = MetaFilter([
BasicMetadata({ metadata }),
Expand Down
51 changes: 33 additions & 18 deletions packages/next/src/lib/metadata/resolve-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { resolveTitle } from './resolvers/resolve-title'
import { resolveAsArrayOrUndefined } from './generate/utils'
import { isClientReference } from '../client-reference'
import {
getErrorOrLayoutModule,
getLayoutOrPageModule,
LoaderTree,
} from '../../server/lib/app-dir-module'
Expand Down Expand Up @@ -248,28 +249,28 @@ function merge({
async function getDefinedMetadata(
mod: any,
props: any,
route: string
tracingProps: { route: string }
): Promise<Metadata | MetadataResolver | null> {
// Layer is a client component, we just skip it. It can't have metadata exported.
// Return early to avoid accessing properties error for client references.
if (isClientReference(mod)) {
return null
}
return (
(mod.generateMetadata
? (parent: ResolvingMetadata) =>
getTracer().trace(
ResolveMetadataSpan.generateMetadata,
{
spanName: `generateMetadata ${route}`,
attributes: {
'next.page': route,
},
},
() => mod.generateMetadata(props, parent)
)
: mod.metadata) || null
)
if (typeof mod.generateMetadata === 'function') {
const { route } = tracingProps
return (parent: ResolvingMetadata) =>
getTracer().trace(
ResolveMetadataSpan.generateMetadata,
{
spanName: `generateMetadata ${route}`,
attributes: {
'next.page': route,
},
},
() => mod.generateMetadata(props, parent)
)
}
return mod.metadata || null
}

async function collectStaticImagesFiles(
Expand Down Expand Up @@ -317,21 +318,30 @@ export async function collectMetadata({
metadataItems: array,
props,
route,
errorConvention,
}: {
tree: LoaderTree
metadataItems: MetadataItems
props: any
route: string
errorConvention?: 'not-found'
}) {
const [mod, modType] = await getLayoutOrPageModule(tree)
let mod
let modType
if (errorConvention) {
mod = await getErrorOrLayoutModule(tree, errorConvention)
modType = errorConvention
} else {
;[mod, modType] = await getLayoutOrPageModule(tree)
}

if (modType) {
route += `/${modType}`
}

const staticFilesMetadata = await resolveStaticMetadata(tree[2], props)
const metadataExport = mod
? await getDefinedMetadata(mod, props, route)
? await getDefinedMetadata(mod, props, { route })
: null

array.push([metadataExport, staticFilesMetadata])
Expand All @@ -344,6 +354,7 @@ export async function resolveMetadata({
treePrefix = [],
getDynamicParamFromSegment,
searchParams,
errorConvention,
}: {
tree: LoaderTree
parentParams: { [key: string]: any }
Expand All @@ -352,10 +363,12 @@ export async function resolveMetadata({
treePrefix?: string[]
getDynamicParamFromSegment: GetDynamicParamFromSegment
searchParams: { [key: string]: any }
errorConvention: 'not-found' | undefined
}): Promise<MetadataItems> {
const [segment, parallelRoutes, { page }] = tree
const currentTreePrefix = [...treePrefix, segment]
const isPage = typeof page !== 'undefined'

// Handle dynamic segment params.
const segmentParam = getDynamicParamFromSegment(segment)
/**
Expand All @@ -379,6 +392,7 @@ export async function resolveMetadata({
await collectMetadata({
tree,
metadataItems,
errorConvention,
props: layerProps,
route: currentTreePrefix
// __PAGE__ shouldn't be shown in a route
Expand All @@ -395,6 +409,7 @@ export async function resolveMetadata({
treePrefix: currentTreePrefix,
searchParams,
getDynamicParamFromSegment,
errorConvention,
})
}

Expand Down
46 changes: 29 additions & 17 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,6 @@ import { ModuleReference } from '../../build/webpack/loaders/metadata/types'

export const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'

const emptyLoaderTree: LoaderTree = ['', {}, {}]

export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
segment: string
Expand Down Expand Up @@ -1361,12 +1359,13 @@ export async function renderToHTMLOrFlight(
query
)

const createMetadata = (tree: LoaderTree) => (
const createMetadata = (tree: LoaderTree, errorType?: 'not-found') => (
// Adding key={requestId} to make metadata remount for each render
// @ts-expect-error allow to use async server component
<MetadataTree
key={requestId}
tree={tree}
errorType={errorType}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
Expand All @@ -1388,12 +1387,12 @@ export async function renderToHTMLOrFlight(
assetPrefix={assetPrefix}
initialCanonicalUrl={pathname}
initialTree={initialTree}
initialHead={<>{createMetadata(loaderTree)}</>}
initialHead={<>{createMetadata(loaderTree, undefined)}</>}
globalErrorComponent={GlobalError}
notFound={
NotFound ? (
<ErrorHtml>
{createMetadata(loaderTree)}
{createMetadata(loaderTree, 'not-found')}
{notFoundStyles}
<NotFound />
</ErrorHtml>
Expand Down Expand Up @@ -1594,7 +1593,9 @@ export async function renderToHTMLOrFlight(
if (isNotFoundError(err)) {
res.statusCode = 404
}
let hasRedirectError = false
if (isRedirectError(err)) {
hasRedirectError = true
res.statusCode = 307
if (err.mutableCookies) {
const headers = new Headers()
Expand All @@ -1609,7 +1610,7 @@ export async function renderToHTMLOrFlight(
}

const use404Error = res.statusCode === 404
const useDefaultError = res.statusCode < 400 || res.statusCode === 307
const useDefaultError = res.statusCode < 400 || hasRedirectError

const { layout } = loaderTree[2]
const injectedCSS = new Set<string>()
Expand All @@ -1624,19 +1625,28 @@ export async function renderToHTMLOrFlight(
? interopDefault(await rootLayoutModule())
: null

const metadata = (
// @ts-expect-error allow to use async server component
<MetadataTree
key={requestId}
tree={loaderTree}
pathname={pathname}
errorType={
use404Error
? 'not-found'
: hasRedirectError
? 'redirect'
: undefined
}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
)
const serverErrorElement = (
<ErrorHtml
head={
// @ts-expect-error allow to use async server component
<MetadataTree
key={requestId}
tree={emptyLoaderTree}
pathname={pathname}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
}
// For default error we render metadata directly into the head
head={useDefaultError ? metadata : null}
>
{useDefaultError
? null
Expand All @@ -1645,6 +1655,8 @@ export async function renderToHTMLOrFlight(
async () => {
return (
<>
{/* For server components error metadata needs to be inside inline flight data, so they can be hydrated */}
{metadata}
{use404Error ? (
<RootLayout params={{}}>
{notFoundStyles}
Expand Down
14 changes: 14 additions & 0 deletions packages/next/src/server/lib/app-dir-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ export async function getLayoutOrPageModule(loaderTree: LoaderTree) {

return [value, modType] as const
}

// First check not-found, if it doesn't exist then pick layout
export async function getErrorOrLayoutModule(
loaderTree: LoaderTree,
errorType: 'error' | 'not-found'
) {
const { [errorType]: error, layout } = loaderTree[2]
if (typeof error !== 'undefined') {
return await error[0]()
} else if (typeof layout !== 'undefined') {
return await layout[0]()
}
return undefined
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/metadata/app/async/not-found/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function notFound() {
return <h2>local found boundary</h2>
}

export const metadata = {
title: 'Local not found',
description: 'Local not found description',
}
1 change: 1 addition & 0 deletions test/e2e/app-dir/metadata/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export default function Layout({ children }) {
export const metadata = {
title: 'this is the layout title',
description: 'this is the layout description',
keywords: ['nextjs', 'react'],
}
5 changes: 5 additions & 0 deletions test/e2e/app-dir/metadata/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export default function notFound() {
return <h2>root not found page</h2>
}

export const metadata = {
title: 'Root not found',
description: 'Root not found description',
}
Loading

0 comments on commit a28ae63

Please sign in to comment.