Skip to content

Commit 6cc85de

Browse files
authored
Add partial support for "use cache" in metadata route handlers (#74835)
Adds support for using `"use cache"` in the special metadata route handlers like [`sitemap.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-a-sitemap-using-code-js-ts), [`opengraph-image.tsx`](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#generate-images-using-code-js-ts-tsx), [`icon.tsx`](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons#generate-icons-using-code-js-ts-tsx), and other [metadata files](https://nextjs.org/docs/app/api-reference/file-conventions/metadata). reverts #71225 fixes #74146 closes NAR-51 As a follow-up we need to ensure that opengraph image responses do not bail out of static generation when `dynamicIO` is enabled.
1 parent 58255f9 commit 6cc85de

File tree

16 files changed

+610
-352
lines changed

16 files changed

+610
-352
lines changed

Diff for: crates/next-api/src/app.rs

+271-320
Large diffs are not rendered by default.

Diff for: packages/next/src/build/webpack-config.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,17 @@ export default async function getBaseWebpackConfig(
13091309
},
13101310
],
13111311
},
1312+
resourceQuery: {
1313+
// Do not apply next-flight-loader to imports generated by the
1314+
// next-metadata-image-loader, to avoid generating unnecessary
1315+
// and conflicting entries in the flight client entry plugin.
1316+
// These are already covered by the next-metadata-route-loader
1317+
// entries.
1318+
not: [
1319+
new RegExp(WEBPACK_RESOURCE_QUERIES.metadata),
1320+
new RegExp(WEBPACK_RESOURCE_QUERIES.metadataImageMeta),
1321+
],
1322+
},
13121323
resolve: {
13131324
mainFields: getMainField(compilerType, true),
13141325
conditionNames: reactServerCondition,

Diff for: packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts

+29-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
} from '../loaders/next-flight-client-entry-loader'
55

66
import { webpack } from 'next/dist/compiled/webpack/webpack'
7-
import { stringify } from 'querystring'
7+
import { parse, stringify } from 'querystring'
88
import path from 'path'
99
import { sources } from 'next/dist/compiled/webpack/webpack'
1010
import {
@@ -13,7 +13,10 @@ import {
1313
EntryTypes,
1414
getEntryKey,
1515
} from '../../../server/dev/on-demand-entry-handler'
16-
import { WEBPACK_LAYERS } from '../../../lib/constants'
16+
import {
17+
WEBPACK_LAYERS,
18+
WEBPACK_RESOURCE_QUERIES,
19+
} from '../../../lib/constants'
1720
import {
1821
APP_CLIENT_INTERNALS,
1922
BARREL_OPTIMIZATION_PREFIX,
@@ -41,6 +44,7 @@ import { PAGE_TYPES } from '../../../lib/page-types'
4144
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
4245
import { getAssumedSourceType } from '../loaders/next-flight-loader'
4346
import { isAppRouteRoute } from '../../../lib/is-app-route-route'
47+
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'
4448

4549
interface Options {
4650
dev: boolean
@@ -296,10 +300,14 @@ export class FlightClientEntryPlugin {
296300
compilation.moduleGraph
297301
)) {
298302
// Entry can be any user defined entry files such as layout, page, error, loading, etc.
299-
const entryRequest = (
303+
let entryRequest = (
300304
connection.dependency as unknown as webpack.NormalModule
301305
).request
302306

307+
if (entryRequest.endsWith(WEBPACK_RESOURCE_QUERIES.metadataRoute)) {
308+
entryRequest = getMetadataRouteResource(entryRequest)
309+
}
310+
303311
const { clientComponentImports, actionImports, cssImports } =
304312
this.collectComponentInfoFromServerEntryDependency({
305313
entryRequest,
@@ -332,10 +340,16 @@ export class FlightClientEntryPlugin {
332340
: entryRequest
333341

334342
// Replace file suffix as `.js` will be added.
335-
const bundlePath = normalizePathSep(
343+
let bundlePath = normalizePathSep(
336344
relativeRequest.replace(/\.[^.\\/]+$/, '').replace(/^src[\\/]/, '')
337345
)
338346

347+
// For metadata routes, the entry name can be used as the bundle path,
348+
// as it has been normalized already.
349+
if (isMetadataRoute(bundlePath)) {
350+
bundlePath = name
351+
}
352+
339353
Object.assign(mergedCSSimports, cssImports)
340354
clientEntriesToInject.push({
341355
compiler,
@@ -1094,5 +1108,16 @@ function getModuleResource(mod: webpack.NormalModule): string {
10941108
if (mod.matchResource?.startsWith(BARREL_OPTIMIZATION_PREFIX)) {
10951109
modResource = mod.matchResource + ':' + modResource
10961110
}
1111+
1112+
if (mod.resource === `?${WEBPACK_RESOURCE_QUERIES.metadataRoute}`) {
1113+
return getMetadataRouteResource(mod.rawRequest)
1114+
}
1115+
10971116
return modResource
10981117
}
1118+
1119+
function getMetadataRouteResource(request: string): string {
1120+
const query = request.split('next-metadata-route-loader?')[1]
1121+
1122+
return parse(query).filePath as string
1123+
}

Diff for: packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
} from '../utils'
2727
import type { ChunkGroup } from 'webpack'
2828
import { encodeURIPath } from '../../../shared/lib/encode-uri-path'
29-
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'
3029
import type { ModuleInfo } from './flight-client-entry-plugin'
3130

3231
interface Options {
@@ -559,9 +558,9 @@ export class ClientReferenceManifestPlugin {
559558
manifestEntryFiles.push(entryName.replace(/\/page(\.[^/]+)?$/, '/page'))
560559
}
561560

562-
// We also need to create manifests for route handler entrypoints
563-
// (excluding metadata route handlers) to enable `'use cache'`.
564-
if (/\/route$/.test(entryName) && !isMetadataRoute(entryName)) {
561+
// We also need to create manifests for route handler entrypoints to
562+
// enable `'use cache'`.
563+
if (/\/route$/.test(entryName)) {
565564
manifestEntryFiles.push(entryName)
566565
}
567566

Diff for: packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts

+9-13
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import picomatch from 'next/dist/compiled/picomatch'
1919
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
2020
import { getPageFilePath } from '../../entries'
2121
import { resolveExternal } from '../../handle-externals'
22-
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'
2322

2423
const PLUGIN_NAME = 'TraceEntryPointsPlugin'
2524
export const TRACE_IGNORES = [
@@ -243,18 +242,15 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
243242
)
244243

245244
if (entrypoint.name.startsWith('app/')) {
246-
// Include the client reference manifest for pages and route handlers,
247-
// excluding metadata route handlers.
248-
const clientManifestsForEntrypoint = isMetadataRoute(entrypoint.name)
249-
? null
250-
: nodePath.join(
251-
outputPath,
252-
outputPrefix,
253-
entrypoint.name.replace(/%5F/g, '_') +
254-
'_' +
255-
CLIENT_REFERENCE_MANIFEST +
256-
'.js'
257-
)
245+
// include the client reference manifest
246+
const clientManifestsForEntrypoint = nodePath.join(
247+
outputPath,
248+
outputPrefix,
249+
entrypoint.name.replace(/%5F/g, '_') +
250+
'_' +
251+
CLIENT_REFERENCE_MANIFEST +
252+
'.js'
253+
)
258254

259255
if (clientManifestsForEntrypoint !== null) {
260256
entryFiles.add(clientManifestsForEntrypoint)

Diff for: packages/next/src/build/webpack/utils.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {
77
ModuleGraph,
88
} from 'webpack'
99
import type { ModuleGraphConnection } from 'webpack'
10-
import { isMetadataRoute } from '../../lib/metadata/is-metadata-route'
1110

1211
export function traverseModules(
1312
compilation: Compilation,
@@ -48,11 +47,7 @@ export function forEachEntryModule(
4847
) {
4948
for (const [name, entry] of compilation.entries.entries()) {
5049
// Skip for entries under pages/
51-
if (
52-
name.startsWith('pages/') ||
53-
// Skip for metadata route handlers
54-
(name.startsWith('app/') && isMetadataRoute(name))
55-
) {
50+
if (name.startsWith('pages/')) {
5651
continue
5752
}
5853

Diff for: packages/next/src/server/load-components.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { wait } from '../lib/wait'
3232
import { setReferenceManifestsSingleton } from './app-render/encryption-utils'
3333
import { createServerModuleMap } from './app-render/action-utils'
3434
import type { DeepReadonly } from '../shared/lib/deep-readonly'
35-
import { isMetadataRoute } from '../lib/metadata/is-metadata-route'
3635
import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path'
3736

3837
export type ManifestItem = {
@@ -169,9 +168,6 @@ async function loadComponentsImpl<N = any>({
169168
])
170169
}
171170

172-
// Make sure to avoid loading the manifest for metadata route handlers.
173-
const hasClientManifest = isAppPath && !isMetadataRoute(page)
174-
175171
// In dev mode we retry loading a manifest file to handle a race condition
176172
// that can occur while app and pages are compiling at the same time, and the
177173
// build-manifest is still being written to disk while an app path is
@@ -227,7 +223,7 @@ async function loadComponentsImpl<N = any>({
227223
join(distDir, `${DYNAMIC_CSS_MANIFEST}.json`),
228224
manifestLoadAttempts
229225
).catch(() => undefined),
230-
hasClientManifest
226+
isAppPath
231227
? tryLoadClientReferenceManifest(
232228
join(
233229
distDir,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ImageResponse } from 'next/og'
2+
import { setTimeout } from 'timers/promises'
3+
4+
export const size = { width: 32, height: 32 }
5+
export const contentType = 'image/png'
6+
7+
async function fetchIconLetter() {
8+
'use cache'
9+
10+
// Simulate I/O
11+
await setTimeout(100)
12+
13+
return 'N'
14+
}
15+
16+
export default async function Icon() {
17+
const letter = await fetchIconLetter()
18+
19+
return new ImageResponse(
20+
(
21+
<div
22+
style={{
23+
fontSize: 24,
24+
background: 'black',
25+
width: '100%',
26+
height: '100%',
27+
display: 'flex',
28+
alignItems: 'center',
29+
justifyContent: 'center',
30+
color: 'white',
31+
}}
32+
>
33+
{letter}
34+
</div>
35+
),
36+
{ ...size }
37+
)
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { MetadataRoute } from 'next'
2+
import { getSentinelValue } from './sentinel'
3+
import { setTimeout } from 'timers/promises'
4+
5+
export default async function manifest(): Promise<MetadataRoute.Manifest> {
6+
'use cache'
7+
8+
// Simulate I/O
9+
await setTimeout(100)
10+
11+
return { name: getSentinelValue() }
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ImageResponse } from 'next/og'
2+
3+
export const alt = 'About Acme'
4+
export const size = { width: 1200, height: 630 }
5+
export const contentType = 'image/png'
6+
7+
async function fetchPostData() {
8+
'use cache'
9+
10+
return { title: 'Test', created: Date.now() }
11+
}
12+
13+
export default async function Image() {
14+
const post = await fetchPostData()
15+
16+
return new ImageResponse(
17+
(
18+
<div
19+
style={{
20+
fontSize: 48,
21+
background: 'white',
22+
width: '100%',
23+
height: '100%',
24+
display: 'flex',
25+
alignItems: 'center',
26+
justifyContent: 'center',
27+
flexDirection: 'column',
28+
}}
29+
>
30+
<h1>{post.title}</h1>
31+
<p style={{ fontSize: 32 }}>
32+
{new Date(post.created).toLocaleTimeString()}
33+
</p>
34+
</div>
35+
),
36+
size
37+
)
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { MetadataRoute } from 'next'
2+
import { getSentinelValue } from '../sentinel'
3+
import { setTimeout } from 'timers/promises'
4+
5+
export async function generateSitemaps() {
6+
return [{ id: 0 }, { id: 1 }]
7+
}
8+
9+
export default async function sitemap({
10+
id,
11+
}: {
12+
id: number
13+
}): Promise<MetadataRoute.Sitemap> {
14+
'use cache'
15+
16+
// Simulate I/O
17+
await setTimeout(100)
18+
19+
return [{ url: `https://acme.com/${id}?sentinel=${getSentinelValue()}` }]
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { MetadataRoute } from 'next'
2+
import { getSentinelValue } from './sentinel'
3+
import { setTimeout } from 'timers/promises'
4+
5+
export default async function robots(): Promise<MetadataRoute.Robots> {
6+
'use cache'
7+
8+
// Simulate I/O
9+
await setTimeout(100)
10+
11+
return {
12+
rules: { userAgent: '*', allow: `/${getSentinelValue()}` },
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const { PHASE_PRODUCTION_BUILD } = require('next/constants')
2+
3+
export function getSentinelValue() {
4+
return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
5+
? 'buildtime'
6+
: 'runtime'
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { MetadataRoute } from 'next'
2+
import { getSentinelValue } from './sentinel'
3+
import { setTimeout } from 'timers/promises'
4+
5+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
6+
'use cache'
7+
8+
// Simulate I/O
9+
await setTimeout(100)
10+
11+
return [{ url: `https://acme.com?sentinel=${getSentinelValue()}` }]
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
experimental: {
6+
dynamicIO: true,
7+
},
8+
}
9+
10+
module.exports = nextConfig

0 commit comments

Comments
 (0)