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:
parent
659a75177b
commit
a1c04b0162
16 changed files with 790 additions and 689 deletions
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
// =============
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ export const metadata = {
|
|||
app_name: 'app_name_android',
|
||||
},
|
||||
web: {
|
||||
url: 'https://example.com/web',
|
||||
should_fallback: true,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
32
test/e2e/app-dir/metadata/app/cache-deduping/page.tsx
Normal file
32
test/e2e/app-dir/metadata/app/cache-deduping/page.tsx
Normal 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 }),
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
18
test/e2e/app-dir/metadata/app/params/[slug]/page.tsx
Normal file
18
test/e2e/app-dir/metadata/app/params/[slug]/page.tsx
Normal 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']),
|
||||
}
|
||||
}
|
9
test/e2e/app-dir/metadata/app/params/layout.tsx
Normal file
9
test/e2e/app-dir/metadata/app/params/layout.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default function layout({ children }) {
|
||||
return children
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
return {
|
||||
keywords: 'parent',
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue