Support generateMetadata export (#45401)Co-authored-by: Tim Neutkens <tim@timneutkens.nl>

Closes NEXT-397

Resolve `metadata` and `generateMetadata()` exports along with head
during rendering, this is the easy way for now to collect all the
metadata properly. Since we can access segment params and search params
only in rendering, so I moved all the resolving logic from loader to
render process.

<!--
Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change that you're making:
-->


## Feature

- [x] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [x]
[e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

---------

Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
This commit is contained in:
Jiachi Liu 2023-02-01 12:54:39 +01:00 committed by GitHub
parent 659a75177b
commit a1c04b0162
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 790 additions and 689 deletions

View file

@ -7,7 +7,6 @@ import { sep } from 'path'
import { verifyRootLayout } from '../../../lib/verifyRootLayout'
import * as Log from '../../../build/output/log'
import { APP_DIR_ALIAS } from '../../../lib/constants'
import { resolveFileBasedMetadataForLoader } from '../../../lib/metadata/resolve-metadata'
const FILE_TYPES = {
layout: 'layout',
@ -56,7 +55,6 @@ async function createTreeCodeFromPath({
segments: string[]
): Promise<{
treeCode: string
treeMetadataCode: string
}> {
const segmentPath = segments.join('/')
@ -71,26 +69,12 @@ async function createTreeCodeFromPath({
parallelSegments.push(...resolveParallelSegments(segmentPath))
}
let metadataCode = ''
for (const [parallelKey, parallelSegment] of parallelSegments) {
if (parallelSegment === PAGE_SEGMENT) {
const matchedPagePath = `${appDirPrefix}${segmentPath}/page`
const resolvedPagePath = await resolve(matchedPagePath)
if (resolvedPagePath) pages.push(resolvedPagePath)
metadataCode += `{
type: 'page',
layer: ${
// There's an extra virtual segment.
segments.length - 1
},
mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify(
resolvedPagePath
)}),
path: ${JSON.stringify(resolvedPagePath)},
},`
// Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it.
props[parallelKey] = `['', {}, {
page: [() => import(/* webpackMode: "eager" */ ${JSON.stringify(
@ -100,8 +84,9 @@ async function createTreeCodeFromPath({
}
const parallelSegmentPath = segmentPath + '/' + parallelSegment
const { treeCode: subtreeCode, treeMetadataCode: subTreeMetadataCode } =
await createSubtreePropsFromSegmentPath([...segments, parallelSegment])
const { treeCode: subtreeCode } = await createSubtreePropsFromSegmentPath(
[...segments, parallelSegment]
)
// `page` is not included here as it's added above.
const filePaths = await Promise.all(
@ -120,23 +105,6 @@ async function createTreeCodeFromPath({
rootLayout = layoutPath
}
// Collect metadata for the layout
if (layoutPath) {
metadataCode += `{
type: 'layout',
layer: ${segments.length},
mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify(
layoutPath
)}),
path: ${JSON.stringify(layoutPath)},
},`
}
metadataCode += await resolveFileBasedMetadataForLoader(
segments.length,
(await resolve(`${appDirPrefix}${parallelSegmentPath}/`, true))!
)
metadataCode += subTreeMetadataCode
if (!rootLayout) {
rootLayout = layoutPath
}
@ -173,15 +141,13 @@ async function createTreeCodeFromPath({
.map(([key, value]) => `${key}: ${value}`)
.join(',\n')}
}`,
treeMetadataCode: metadataCode,
}
}
const { treeCode, treeMetadataCode } =
await createSubtreePropsFromSegmentPath([])
const { treeCode } = await createSubtreePropsFromSegmentPath([])
return {
treeCode: `const tree = ${treeCode}.children;`,
treeMetadataCode: `const metadata = [${treeMetadataCode}];`,
pages: `const pages = ${JSON.stringify(pages)};`,
rootLayout,
globalError,
@ -279,7 +245,6 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
const {
treeCode,
treeMetadataCode,
pages: pageListCode,
rootLayout,
globalError,
@ -315,7 +280,6 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
const result = `
export ${treeCode}
export ${treeMetadataCode}
export ${pageListCode}
export { default as AppRouter } from 'next/dist/client/components/app-router'

View file

@ -24,6 +24,7 @@ function createTypeGuardFile(
) {
return `// File: ${fullPath}
import * as entry from '${relativePath}'
import type { ResolvingMetadata } from 'next/dist/lib/metadata/types/metadata-interface'
type TEntry = typeof entry
check<IEntry, TEntry>(entry)
@ -65,6 +66,7 @@ interface IEntry {
: ''
}
metadata?: any
generateMetadata?: (props: PageProps, parent: ResolvingMetadata) => Promise<any>
}
// =============

View file

@ -1,4 +1,4 @@
import type { ResolvedMetadata } from './types/metadata-interface'
import type { Metadata } from './types/metadata-interface'
import React from 'react'
import {
@ -14,14 +14,13 @@ import {
TwitterMetadata,
AppLinksMeta,
} from './generate/opengraph'
import { resolveMetadata } from './resolve-metadata'
import { IconsMetadata } from './generate/icons'
import { accumulateMetadata } from './resolve-metadata'
// Generate the actual React elements from the resolved metadata.
export async function Metadata({ metadata }: { metadata: any }) {
if (!metadata) return null
export async function MetadataTree({ metadata }: { metadata: Metadata[] }) {
const resolved = await accumulateMetadata(metadata)
const resolved: ResolvedMetadata = await resolveMetadata(metadata)
return (
<>
<BasicMetadata metadata={resolved} />

View file

@ -30,35 +30,6 @@ const viewPortKeys = {
viewportFit: 'viewport-fit',
} as const
type Item =
| {
type: 'layout' | 'page'
// A number that represents which layer or routes that the item is in. Starting from 0.
// Layout and page in the same level will share the same `layer`.
layer: number
mod: () => Promise<{
metadata?: Metadata
generateMetadata?: (
props: any,
parent: ResolvingMetadata
) => Promise<Metadata>
}>
path: string
}
| {
type: 'icon'
// A number that represents which layer the item is in. Starting from 0.
layer: number
mod?: () => Promise<{
metadata?: Metadata
generateMetadata?: (
props: any,
parent: ResolvingMetadata
) => Promise<Metadata>
}>
path?: string
}
const resolveViewport: FieldResolver<'viewport'> = (viewport) => {
let resolved: ResolvedMetadata['viewport'] = null
@ -338,85 +309,80 @@ function merge(
case 'itunes':
case 'alternates':
case 'formatDetection':
case 'other':
// @ts-ignore TODO: support inferring
target[key] = source[key] || null
break
case 'other':
target.other = Object.assign({}, target.other, source.other)
break
default:
break
}
}
}
export async function resolveMetadata(metadataItems: Item[]) {
const resolvedMetadata = createDefaultMetadata()
type MetadataResolver = (_parent: ResolvingMetadata) => Promise<Metadata>
export type MetadataItems = (Metadata | MetadataResolver)[]
let committedTitleTemplate: string | null = null
let committedOpenGraphTitleTemplate: string | null = null
let committedTwitterTitleTemplate: string | null = null
let lastLayer = 0
// from root layout to page metadata
for (let i = 0; i < metadataItems.length; i++) {
const item = metadataItems[i]
const isLayout = item.type === 'layout'
const isPage = item.type === 'page'
if (isLayout || isPage) {
let layerMod = await item.mod()
// Layer is a client component, we just skip it. It can't have metadata
// exported. Note that during our SWC transpilation, it should check if
// the exports are valid and give specific error messages.
if (
'$$typeof' in layerMod &&
(layerMod as any).$$typeof === Symbol.for('react.module.reference')
) {
continue
}
if (layerMod.metadata && layerMod.generateMetadata) {
throw new Error(
`A ${item.type} is exporting both metadata and generateMetadata which is not supported. If all of the metadata you want to associate to this ${item.type} is static use the metadata export, otherwise use generateMetadata. File: ` +
item.path
)
}
// If we resolved all items in this layer, commit the stashed titles.
if (item.layer >= lastLayer) {
committedTitleTemplate = resolvedMetadata.title?.template || null
committedOpenGraphTitleTemplate =
resolvedMetadata.openGraph?.title?.template || null
committedTwitterTitleTemplate =
resolvedMetadata.twitter?.title?.template || null
lastLayer = item.layer
}
if (layerMod.metadata) {
merge(resolvedMetadata, layerMod.metadata, {
title: committedTitleTemplate,
openGraph: committedOpenGraphTitleTemplate,
twitter: committedTwitterTitleTemplate,
})
} else if (layerMod.generateMetadata) {
merge(
resolvedMetadata,
await layerMod.generateMetadata(
// TODO: Rewrite this to pass correct params and resolving metadata value.
{},
Promise.resolve(resolvedMetadata)
),
{
title: committedTitleTemplate,
openGraph: committedOpenGraphTitleTemplate,
twitter: committedTwitterTitleTemplate,
}
)
}
}
async function getDefinedMetadata(
mod: any,
props: any
): Promise<Metadata | MetadataResolver | null> {
// Layer is a client component, we just skip it. It can't have metadata
// exported. Note that during our SWC transpilation, it should check if
// the exports are valid and give specific error messages.
if (
'$$typeof' in mod &&
(mod as any).$$typeof === Symbol.for('react.module.reference')
) {
return null
}
return resolvedMetadata
if (mod.metadata && mod.generateMetadata) {
throw new Error(
`${mod.path} is exporting both metadata and generateMetadata which is not supported. If all of the metadata you want to associate to this page/layout is static use the metadata export, otherwise use generateMetadata. File: ${mod.path}`
)
}
return mod.generateMetadata
? (parent: ResolvingMetadata) => mod.generateMetadata(props, parent)
: mod.metadata
}
// layout.metadata -> layout.metadata -> page.metadata
export async function collectMetadata(
mod: any,
props: any,
array: MetadataItems
) {
if (!mod) return
const metadata = await getDefinedMetadata(mod, props)
if (metadata) {
array.push(metadata)
}
}
export async function accumulateMetadata(
metadataItems: MetadataItems
): Promise<ResolvedMetadata> {
const resolvedMetadata = createDefaultMetadata()
let parentPromise = Promise.resolve(resolvedMetadata)
for (const item of metadataItems) {
const layerMetadataPromise =
typeof item === 'function' ? item(parentPromise) : Promise.resolve(item)
parentPromise = parentPromise.then((resolved) => {
return layerMetadataPromise.then((metadata) => {
merge(resolved, metadata, {
title: resolved.title?.template || null,
openGraph: resolved.openGraph?.title?.template || null,
twitter: resolved.twitter?.title?.template || null,
})
return resolved
})
})
}
return await parentPromise
}
// TODO: Implement this function.

View file

@ -27,7 +27,7 @@ import type {
Verification,
} from './metadata-types'
import type { OpenGraph, ResolvedOpenGraph } from './opengraph-types'
import { ResolvedTwitterMetadata, Twitter } from './twitter-types'
import type { ResolvedTwitterMetadata, Twitter } from './twitter-types'
export interface Metadata {
// origin and base path for absolute urls for various metadata links such as

View file

@ -48,9 +48,11 @@ import {
import type { StaticGenerationAsyncStorage } from '../client/components/static-generation-async-storage'
import type { RequestAsyncStorage } from '../client/components/request-async-storage'
import { formatServerError } from '../lib/format-server-error'
import { Metadata } from '../lib/metadata/metadata'
import { MetadataTree } from '../lib/metadata/metadata'
import { runWithRequestAsyncStorage } from './run-with-request-async-storage'
import { runWithStaticGenerationAsyncStorage } from './run-with-static-generation-async-storage'
import { collectMetadata } from '../lib/metadata/resolve-metadata'
import type { MetadataItems } from '../lib/metadata/resolve-metadata'
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
@ -92,6 +94,14 @@ const INTERNAL_HEADERS_INSTANCE = Symbol('internal for headers readonly')
function readonlyHeadersError() {
return new Error('ReadonlyHeaders cannot be modified')
}
async function getLayoutOrPageModule(loaderTree: LoaderTree) {
const { layout, page } = loaderTree[2]
const isLayout = typeof layout !== 'undefined'
const isPage = typeof page !== 'undefined'
return isLayout ? await layout[0]() : isPage ? await page[0]() : undefined
}
export class ReadonlyHeaders {
[INTERNAL_HEADERS_INSTANCE]: Headers
@ -980,7 +990,7 @@ export async function renderToHTMLOrFlight(
*/
const requestId = nanoid(12)
const metadataItems = ComponentMod.metadata
const searchParamsProps = { searchParams: query }
stripInternalQueries(query)
@ -1064,9 +1074,12 @@ export async function renderToHTMLOrFlight(
}
async function resolveHead(
[segment, parallelRoutes, { head }]: LoaderTree,
parentParams: { [key: string]: any }
): Promise<React.ReactNode> {
tree: LoaderTree,
parentParams: { [key: string]: any },
metadataItems: MetadataItems
): Promise<[React.ReactNode, MetadataItems]> {
const [segment, parallelRoutes, { head, page }] = tree
const isPage = typeof page !== 'undefined'
// Handle dynamic segment params.
const segmentParam = getDynamicParamFromSegment(segment)
/**
@ -1081,20 +1094,33 @@ export async function renderToHTMLOrFlight(
}
: // Pass through parent params to children
parentParams
const layerProps = {
params: currentParams,
...(isPage && searchParamsProps),
}
const mod = await getLayoutOrPageModule(tree)
await collectMetadata(mod, layerProps, metadataItems)
for (const key in parallelRoutes) {
const childTree = parallelRoutes[key]
const returnedHead = await resolveHead(childTree, currentParams)
const [returnedHead] = await resolveHead(
childTree,
currentParams,
metadataItems
)
if (returnedHead) {
return returnedHead
return [returnedHead, metadataItems]
}
}
if (head) {
const Head = await interopDefault(await head[0]())
return <Head params={currentParams} />
return [<Head params={currentParams} />, metadataItems]
}
return null
return [null, metadataItems]
}
const createFlightRouterStateFromLoaderTree = (
@ -1185,11 +1211,7 @@ export async function renderToHTMLOrFlight(
*/
const createComponentTree = async ({
createSegmentPath,
loaderTree: [
segment,
parallelRoutes,
{ layout, template, error, loading, page, 'not-found': notFound },
],
loaderTree: tree,
parentParams,
firstItem,
rootLayoutIncluded,
@ -1202,6 +1224,15 @@ export async function renderToHTMLOrFlight(
firstItem?: boolean
injectedCSS: Set<string>
}): Promise<{ Component: React.ComponentType }> => {
const [segment, parallelRoutes, components] = tree
const {
layout,
template,
error,
loading,
page,
'not-found': notFound,
} = components
const layoutOrPagePath = layout?.[1] || page?.[1]
const injectedCSSWithCurrentLayout = new Set(injectedCSS)
@ -1253,11 +1284,7 @@ export async function renderToHTMLOrFlight(
const isLayout = typeof layout !== 'undefined'
const isPage = typeof page !== 'undefined'
const layoutOrPageMod = isLayout
? await layout[0]()
: isPage
? await page[0]()
: undefined
const layoutOrPageMod = await getLayoutOrPageModule(tree)
/**
* Checks if the current segment is a root layout.
@ -1373,7 +1400,8 @@ export async function renderToHTMLOrFlight(
? [parallelRouteKey]
: [actualSegment, parallelRouteKey]
const childSegment = parallelRoutes[parallelRouteKey][0]
const parallelRoute = parallelRoutes[parallelRouteKey]
const childSegment = parallelRoute[0]
const childSegmentParam = getDynamicParamFromSegment(childSegment)
if (isPrefetch && Loading) {
@ -1414,7 +1442,7 @@ export async function renderToHTMLOrFlight(
createSegmentPath: (child) => {
return createSegmentPath([...currentSegmentPath, ...child])
},
loaderTree: parallelRoutes[parallelRouteKey],
loaderTree: parallelRoute,
parentParams: currentParams,
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
injectedCSS: injectedCSSWithCurrentLayout,
@ -1479,7 +1507,7 @@ export async function renderToHTMLOrFlight(
// If you have a `/dashboard/[team]/layout.js` it will provide `team` as a param but not anything further down.
params: currentParams,
// Query is only provided to page
...(isPage ? { searchParams: query } : {}),
...(isPage ? searchParamsProps : {}),
}
// Eagerly execute layout/page component to trigger fetches early.
@ -1587,9 +1615,10 @@ export async function renderToHTMLOrFlight(
injectedCSS: Set<string>
rootLayoutIncluded: boolean
}): Promise<FlightDataPath> => {
const [segment, parallelRoutes, { layout }] = loaderTreeToFilter
const isLayout = typeof layout !== 'undefined'
const [segment, parallelRoutes, components] = loaderTreeToFilter
const parallelRoutesKeys = Object.keys(parallelRoutes)
const { layout } = components
const isLayout = typeof layout !== 'undefined'
/**
* Checks if the current segment is a root layout.
@ -1634,7 +1663,7 @@ export async function renderToHTMLOrFlight(
// Create router state using the slice of the loaderTree
createFlightRouterStateFromLoaderTree(loaderTreeToFilter),
// Check if one level down from the common layout has a loading component. If it doesn't only provide the router state as part of the Flight data.
isPrefetch && !Boolean(loaderTreeToFilter[2].loading)
isPrefetch && !Boolean(components.loading)
? null
: // Create component tree using the slice of the loaderTree
// @ts-expect-error TODO-APP: fix async component type
@ -1653,11 +1682,10 @@ export async function renderToHTMLOrFlight(
rootLayoutIncluded: rootLayoutIncluded,
}
)
return <Component />
}),
isPrefetch && !Boolean(loaderTreeToFilter[2].loading) ? null : (
<>{rscPayloadHead}</>
),
isPrefetch && !Boolean(components.loading) ? null : rscPayloadHead,
]
}
@ -1708,7 +1736,11 @@ export async function renderToHTMLOrFlight(
return [actualSegment]
}
const rscPayloadHead = await resolveHead(loaderTree, {})
const [resolvedHead, metadataItems] = await resolveHead(
loaderTree,
{},
[]
)
// Flight data that is going to be passed to the browser.
// Currently a single item array but in the future multiple patches might be combined in a single request.
const flightData: FlightData = [
@ -1719,12 +1751,13 @@ export async function renderToHTMLOrFlight(
parentParams: {},
flightRouterState: providedFlightRouterState,
isFirst: true,
// For flight, render metadata inside leaf page
rscPayloadHead: (
<>
{/* Adding key={requestId} to make metadata remount for each render */}
{/* @ts-expect-error allow to use async server component */}
<Metadata key={requestId} metadata={metadataItems} />
{rscPayloadHead}
<MetadataTree key={requestId} metadata={metadataItems} />
{resolvedHead}
</>
),
injectedCSS: new Set(),
@ -1793,7 +1826,7 @@ export async function renderToHTMLOrFlight(
}
: {}
const initialHead = await resolveHead(loaderTree, {})
const [initialHead, metadataItems] = await resolveHead(loaderTree, {}, [])
/**
* A new React Component that renders the provided React Component
@ -1823,7 +1856,7 @@ export async function renderToHTMLOrFlight(
<>
{/* Adding key={requestId} to make metadata remount for each render */}
{/* @ts-expect-error allow to use async server component */}
<Metadata key={requestId} metadata={metadataItems} />
<MetadataTree key={requestId} metadata={metadataItems} />
{initialHead}
</>
}

View file

@ -13,6 +13,7 @@ export const metadata = {
app_name: 'app_name_android',
},
web: {
url: 'https://example.com/web',
should_fallback: true,
},
},

View file

@ -2,20 +2,23 @@ export default function page() {
return 'apple'
}
export const metadata = {
itunes: {
appId: 'myAppStoreID',
appArgument: 'myAppArgument',
},
appleWebApp: {
title: 'Apple Web App',
statusBarStyle: 'black-translucent',
startupImage: [
'/assets/startup/apple-touch-startup-image-768x1004.png',
{
url: '/assets/startup/apple-touch-startup-image-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)',
},
],
},
export async function generateMetadata() {
const metadata = {
itunes: {
appId: 'myAppStoreID',
appArgument: 'myAppArgument',
},
appleWebApp: {
title: 'Apple Web App',
statusBarStyle: 'black-translucent',
startupImage: [
'/assets/startup/apple-touch-startup-image-768x1004.png',
{
url: '/assets/startup/apple-touch-startup-image-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)',
},
],
},
}
return metadata
}

View file

@ -6,6 +6,10 @@ export default function Page() {
<Link id="to-index" href="/">
to index
</Link>
<br />
<Link href="/title-template/extra/inner" id="to-nested">
to /title-template/extra/inner
</Link>
</div>
)
}

View file

@ -0,0 +1,9 @@
import Link from 'next/link'
export default function Page() {
return (
<Link href="/cache-deduping" id="link-to-deduping-page">
To cache deduping page
</Link>
)
}

View file

@ -0,0 +1,32 @@
import React, { cache } from 'react'
const getRandomMemoized = cache(() => Math.random())
async function getRandomMemoizedByFetch() {
const res = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random'
)
return res.text()
}
export default async function Page(props) {
const val = getRandomMemoized()
const val2 = await getRandomMemoizedByFetch()
return (
<>
<p id="value">{val}</p>
<p id="value2">{val2}</p>
</>
)
}
export async function generateMetadata(props, parent) {
const val = getRandomMemoized()
const val2 = await getRandomMemoizedByFetch()
return {
title: {
default: JSON.stringify({ val, val2 }),
},
}
}

View file

@ -10,11 +10,14 @@ export default function Page() {
to /basic
</Link>
<br />
<Link href="/title" id="to-title">
to /title
</Link>
<br />
<Link href="/title-template/extra/inner" id="to-nested">
to /title-template/extra/inner
</Link>
<br />
</>
)
}

View file

@ -0,0 +1,18 @@
function format({ params, searchParams }) {
const { slug } = params
const { q } = searchParams
return `params - ${slug}${q ? ` query - ${q}` : ''}`
}
export default function page(props) {
return <p>{format(props)}</p>
}
export async function generateMetadata(props, parent) {
const parentMetadata = await parent
return {
...parentMetadata,
title: format(props),
keywords: parentMetadata.keywords.concat(['child']),
}
}

View file

@ -0,0 +1,9 @@
export default function layout({ children }) {
return children
}
export async function generateMetadata() {
return {
keywords: 'parent',
}
}

View file

@ -1,5 +1,17 @@
import Link from 'next/link'
export default function Page() {
return <p>hello</p>
return (
<>
<Link href="/" id="to-index">
to /
</Link>
<br />
<Link href="/basic" id="to-basic">
to /basic
</Link>
</>
)
}
export const metadata = {

File diff suppressed because it is too large Load diff