Skip to content

Commit dbbda2f

Browse files
authored
[Cache Components] Fix caching in generateMetadata/generateViewport (#84228)
When using `'use cache'` in `generateMetadata` or `generateViewport`, the caching did not work correctly, because we didn't apply the same special handling for the `params` and `searchParams` props that we do for layout and page components. This caused the following issues: - `generateMetadata`/`generateViewport` for a page without params, or with static params, yielded cache misses when resuming - `generateMetadata` for a page/layout with unused params, became dynamic when prerendering a fallback shell - `generateMetadata` for a page with used search params, produced a timeout error during prerendering - `generateViewport` for a page/layout with unused params, produced a build error (unless opting into fully dynamic rendering) Still to be done in a follow-up: Omit unused `params` from cache keys, and upgrade cache keys when they are used, to avoid unnecessary cache misses when resuming. closes NAR-321
1 parent c9c6285 commit dbbda2f

File tree

24 files changed

+1010
-155
lines changed

24 files changed

+1010
-155
lines changed

packages/next/src/lib/client-and-server-references.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { extractInfoFromServerReferenceId } from '../shared/lib/server-reference-info'
1+
import {
2+
extractInfoFromServerReferenceId,
3+
type ServerReferenceInfo,
4+
} from '../shared/lib/server-reference-info'
25

36
// Only contains the properties we're interested in.
47
export interface ServerReference {
@@ -27,6 +30,18 @@ export function isUseCacheFunction<T>(
2730
return type === 'use-cache'
2831
}
2932

33+
export function getUseCacheFunctionInfo<T>(
34+
value: T & Partial<ServerReference>
35+
): ServerReferenceInfo | null {
36+
if (!isServerReference(value)) {
37+
return null
38+
}
39+
40+
const info = extractInfoFromServerReferenceId(value.$$id)
41+
42+
return info.type === 'use-cache' ? info : null
43+
}
44+
3045
export function isClientReference(mod: any): boolean {
3146
const defaultExport = mod?.default || mod
3247
return defaultExport?.$$typeof === Symbol.for('react.client.reference')

packages/next/src/lib/metadata/resolve-metadata.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ function mapUrlsToStrings(obj: any) {
4646
describe('accumulateMetadata', () => {
4747
describe('typing', () => {
4848
it('should support both sync and async metadata', async () => {
49+
const generateMetadata = () => Promise.resolve({ description: 'child' })
4950
const metadataItems: MetadataItems = [
5051
[{ description: 'parent' }, null],
51-
[() => Promise.resolve({ description: 'child' }), null],
52+
[
53+
Object.assign(generateMetadata, { $$original: generateMetadata }),
54+
null,
55+
],
5256
]
5357

5458
const metadata = await accumulateMetadata(metadataItems)
@@ -467,16 +471,18 @@ describe('accumulateMetadata', () => {
467471
},
468472
})
469473

474+
function gM2() {
475+
return {
476+
openGraph: {
477+
images: undefined,
478+
},
479+
// twitter is not specified, supposed to merged with openGraph but images should not be picked up
480+
}
481+
}
482+
470483
const metadataItems2: MetadataItems = [
471484
[
472-
function gM2() {
473-
return {
474-
openGraph: {
475-
images: undefined,
476-
},
477-
// twitter is not specified, supposed to merged with openGraph but images should not be picked up
478-
}
479-
},
485+
Object.assign(gM2, { $$original: gM2 }),
480486
// has static metadata files
481487
{
482488
icon: undefined,

packages/next/src/lib/metadata/resolve-metadata.ts

Lines changed: 137 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { ParsedUrlQuery } from 'querystring'
2323
import type { StaticMetadata } from './types/icons'
2424
import type { WorkStore } from '../../server/app-render/work-async-storage.external'
2525
import type { Params } from '../../server/request/params'
26+
import type { SearchParams } from '../../server/request/search-params'
2627

2728
// eslint-disable-next-line import/no-extraneous-dependencies
2829
import 'server-only'
@@ -58,15 +59,31 @@ import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment'
5859
import * as Log from '../../build/output/log'
5960
import { createServerParamsForMetadata } from '../../server/request/params'
6061
import type { MetadataBaseURL } from './resolvers/resolve-url'
62+
import {
63+
getUseCacheFunctionInfo,
64+
isUseCacheFunction,
65+
} from '../client-and-server-references'
66+
import type {
67+
UseCacheLayoutProps,
68+
UseCachePageProps,
69+
} from '../../server/use-cache/use-cache-wrapper'
70+
import { createLazyResult } from '../../server/lib/lazy-result'
6171

6272
type StaticIcons = Pick<ResolvedIcons, 'icon' | 'apple'>
6373

64-
type MetadataResolver = (
65-
parent: ResolvingMetadata
66-
) => Metadata | Promise<Metadata>
67-
type ViewportResolver = (
68-
parent: ResolvingViewport
69-
) => Viewport | Promise<Viewport>
74+
type Resolved<T> = T extends Metadata ? ResolvedMetadata : ResolvedViewport
75+
76+
type InstrumentedResolver<TData> = ((
77+
parent: Promise<Resolved<TData>>
78+
) => TData | Promise<TData>) & {
79+
$$original: (
80+
props: unknown,
81+
parent: Promise<Resolved<TData>>
82+
) => TData | Promise<TData>
83+
}
84+
85+
type MetadataResolver = InstrumentedResolver<Metadata>
86+
type ViewportResolver = InstrumentedResolver<Viewport>
7087

7188
export type MetadataErrorType = 'not-found' | 'forbidden' | 'unauthorized'
7289

@@ -87,13 +104,17 @@ type BuildState = {
87104
}
88105

89106
type LayoutProps = {
90-
params: { [key: string]: any }
107+
params: Promise<Params>
91108
}
109+
92110
type PageProps = {
93-
params: { [key: string]: any }
94-
searchParams: { [key: string]: any }
111+
params: Promise<Params>
112+
searchParams: Promise<SearchParams>
95113
}
96114

115+
type SegmentProps = LayoutProps | PageProps
116+
type UseCacheSegmentProps = UseCacheLayoutProps | UseCachePageProps
117+
97118
function isFavicon(icon: IconDescriptor | undefined): boolean {
98119
if (!icon) {
99120
return false
@@ -461,51 +482,77 @@ function mergeViewport({
461482

462483
function getDefinedViewport(
463484
mod: any,
464-
props: any,
485+
props: SegmentProps,
465486
tracingProps: { route: string }
466487
): Viewport | ViewportResolver | null {
467488
if (typeof mod.generateViewport === 'function') {
468489
const { route } = tracingProps
469-
return (parent: ResolvingViewport) =>
470-
getTracer().trace(
471-
ResolveMetadataSpan.generateViewport,
472-
{
473-
spanName: `generateViewport ${route}`,
474-
attributes: {
475-
'next.page': route,
490+
const segmentProps = createSegmentProps(mod.generateViewport, props)
491+
492+
return Object.assign(
493+
(parent: ResolvingViewport) =>
494+
getTracer().trace(
495+
ResolveMetadataSpan.generateViewport,
496+
{
497+
spanName: `generateViewport ${route}`,
498+
attributes: {
499+
'next.page': route,
500+
},
476501
},
477-
},
478-
() => mod.generateViewport(props, parent)
479-
)
502+
() => mod.generateViewport(segmentProps, parent)
503+
),
504+
{ $$original: mod.generateViewport }
505+
)
480506
}
481507
return mod.viewport || null
482508
}
483509

484510
function getDefinedMetadata(
485511
mod: any,
486-
props: any,
512+
props: SegmentProps,
487513
tracingProps: { route: string }
488514
): Metadata | MetadataResolver | null {
489515
if (typeof mod.generateMetadata === 'function') {
490516
const { route } = tracingProps
491-
return (parent: ResolvingMetadata) =>
492-
getTracer().trace(
493-
ResolveMetadataSpan.generateMetadata,
494-
{
495-
spanName: `generateMetadata ${route}`,
496-
attributes: {
497-
'next.page': route,
517+
const segmentProps = createSegmentProps(mod.generateMetadata, props)
518+
519+
return Object.assign(
520+
(parent: ResolvingMetadata) =>
521+
getTracer().trace(
522+
ResolveMetadataSpan.generateMetadata,
523+
{
524+
spanName: `generateMetadata ${route}`,
525+
attributes: {
526+
'next.page': route,
527+
},
498528
},
499-
},
500-
() => mod.generateMetadata(props, parent)
501-
)
529+
() => mod.generateMetadata(segmentProps, parent)
530+
),
531+
{ $$original: mod.generateMetadata }
532+
)
502533
}
503534
return mod.metadata || null
504535
}
505536

537+
/**
538+
* If `fn` is a `'use cache'` function, we add special markers to the props,
539+
* that the cache wrapper reads and removes, before passing the props to the
540+
* user function.
541+
*/
542+
function createSegmentProps(
543+
fn: Function,
544+
props: SegmentProps
545+
): SegmentProps | UseCacheSegmentProps {
546+
return isUseCacheFunction(fn)
547+
? 'searchParams' in props
548+
? { ...props, $$isPage: true }
549+
: { ...props, $$isLayout: true }
550+
: props
551+
}
552+
506553
async function collectStaticImagesFiles(
507554
metadata: AppDirModules['metadata'],
508-
props: any,
555+
props: SegmentProps,
509556
type: keyof NonNullable<AppDirModules['metadata']>
510557
) {
511558
if (!metadata?.[type]) return undefined
@@ -522,7 +569,7 @@ async function collectStaticImagesFiles(
522569

523570
async function resolveStaticMetadata(
524571
modules: AppDirModules,
525-
props: any
572+
props: SegmentProps
526573
): Promise<StaticMetadata> {
527574
const { metadata } = modules
528575
if (!metadata) return null
@@ -557,7 +604,7 @@ async function collectMetadata({
557604
tree: LoaderTree
558605
metadataItems: MetadataItems
559606
errorMetadataItem: MetadataItems[number]
560-
props: any
607+
props: SegmentProps
561608
route: string
562609
errorConvention?: MetadataErrorType
563610
}) {
@@ -608,7 +655,7 @@ async function collectViewport({
608655
tree: LoaderTree
609656
viewportItems: ViewportItems
610657
errorViewportItemRef: ErrorViewportItemRef
611-
props: any
658+
props: SegmentProps
612659
route: string
613660
errorConvention?: MetadataErrorType
614661
}) {
@@ -700,25 +747,14 @@ async function resolveMetadataItemsImpl(
700747
}
701748

702749
const params = createServerParamsForMetadata(currentParams, workStore)
703-
704-
let layerProps: LayoutProps | PageProps
705-
if (isPage) {
706-
layerProps = {
707-
params,
708-
searchParams,
709-
}
710-
} else {
711-
layerProps = {
712-
params,
713-
}
714-
}
750+
const props: SegmentProps = isPage ? { params, searchParams } : { params }
715751

716752
await collectMetadata({
717753
tree,
718754
metadataItems,
719755
errorMetadataItem,
720756
errorConvention,
721-
props: layerProps,
757+
props,
722758
route: currentTreePrefix
723759
// __PAGE__ shouldn't be shown in a route
724760
.filter((s) => s !== PAGE_SEGMENT_KEY)
@@ -963,7 +999,7 @@ function prerenderMetadata(metadataItems: MetadataItems) {
963999
> = []
9641000
for (let i = 0; i < metadataItems.length; i++) {
9651001
const metadataExport = metadataItems[i][0]
966-
getResult(resolversAndResults, metadataExport)
1002+
getResult<Metadata>(resolversAndResults, metadataExport)
9671003
}
9681004
return resolversAndResults
9691005
}
@@ -977,32 +1013,66 @@ function prerenderViewport(viewportItems: ViewportItems) {
9771013
> = []
9781014
for (let i = 0; i < viewportItems.length; i++) {
9791015
const viewportExport = viewportItems[i]
980-
getResult(resolversAndResults, viewportExport)
1016+
getResult<Viewport>(resolversAndResults, viewportExport)
9811017
}
9821018
return resolversAndResults
9831019
}
9841020

985-
type Resolved<T> = T extends Metadata ? ResolvedMetadata : ResolvedViewport
1021+
const noop = () => {}
9861022

987-
function getResult<T extends Metadata | Viewport>(
988-
resolversAndResults: Array<((value: Resolved<T>) => void) | Result<T>>,
989-
exportForResult: null | T | ((parent: Promise<Resolved<T>>) => Result<T>)
1023+
function getResult<TData extends object>(
1024+
resolversAndResults: Array<
1025+
((value: Resolved<TData>) => void) | Result<TData>
1026+
>,
1027+
exportForResult: null | TData | InstrumentedResolver<TData>
9901028
) {
9911029
if (typeof exportForResult === 'function') {
992-
const result = exportForResult(
993-
new Promise<Resolved<T>>((resolve) => resolversAndResults.push(resolve))
1030+
// If the function is a 'use cache' function that uses the parent data as
1031+
// the second argument, we don't want to eagerly execute it during
1032+
// metadata/viewport pre-rendering, as the parent data might also be
1033+
// computed from another 'use cache' function. To ensure that the hanging
1034+
// input abort signal handling works in this case (i.e. the depending
1035+
// function waits for the cached input to resolve while encoding its args),
1036+
// they must be called sequentially. This can be accomplished by wrapping
1037+
// the call in a lazy promise, so that the original function is only called
1038+
// when the result is actually awaited.
1039+
const useCacheFunctionInfo = getUseCacheFunctionInfo(
1040+
exportForResult.$$original
9941041
)
995-
resolversAndResults.push(result)
996-
if (result instanceof Promise) {
997-
// since we eager execute generateMetadata and
998-
// they can reject at anytime we need to ensure
999-
// we attach the catch handler right away to
1000-
// prevent unhandled rejections crashing the process
1001-
result.catch((err) => {
1002-
return {
1003-
__nextError: err,
1004-
}
1005-
})
1042+
if (useCacheFunctionInfo && useCacheFunctionInfo.usedArgs[1]) {
1043+
const promise = new Promise<Resolved<TData>>((resolve) =>
1044+
resolversAndResults.push(resolve)
1045+
)
1046+
resolversAndResults.push(
1047+
createLazyResult(async () => exportForResult(promise))
1048+
)
1049+
} else {
1050+
let result: TData | Promise<TData>
1051+
if (useCacheFunctionInfo) {
1052+
resolversAndResults.push(noop)
1053+
// @ts-expect-error We intentionally omit the parent argument, because
1054+
// we know from the check above that the 'use cache' function does not
1055+
// use it.
1056+
result = exportForResult()
1057+
} else {
1058+
result = exportForResult(
1059+
new Promise<Resolved<TData>>((resolve) =>
1060+
resolversAndResults.push(resolve)
1061+
)
1062+
)
1063+
}
1064+
resolversAndResults.push(result)
1065+
if (result instanceof Promise) {
1066+
// since we eager execute generateMetadata and
1067+
// they can reject at anytime we need to ensure
1068+
// we attach the catch handler right away to
1069+
// prevent unhandled rejections crashing the process
1070+
result.catch((err) => {
1071+
return {
1072+
__nextError: err,
1073+
}
1074+
})
1075+
}
10061076
}
10071077
} else if (typeof exportForResult === 'object') {
10081078
resolversAndResults.push(exportForResult)

0 commit comments

Comments
 (0)