Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add partial support for "use cache" in metadata route handlers #74835

Merged
merged 8 commits into from
Jan 14, 2025
591 changes: 271 additions & 320 deletions crates/next-api/src/app.rs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,17 @@ export default async function getBaseWebpackConfig(
},
],
},
resourceQuery: {
// Do not apply next-flight-loader to imports generated by the
// next-metadata-image-loader, to avoid generating unnecessary
// and conflicting entries in the flight client entry plugin.
// These are already covered by the next-metadata-route-loader
// entries.
not: [
new RegExp(WEBPACK_RESOURCE_QUERIES.metadata),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 sounds fine to keep as RSC like layers, curious why we need to change them here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They will still be in the RSC layer (via issuer layer), but we don't want to apply the next-flight-loader to them, as that would generate additional and conflicting entries, which are already covered by the next-metadata-route-loader entries.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this exclusion, we would generate duplicate export statements like these:

export { $$RSC_SERVER_CACHE_0 as "8056ace5d0b6cd298fba2780df50a7ec233c33eeef" } from "<snip>/app/opengraph-image.tsx?__next_metadata__"
export { $$RSC_SERVER_CACHE_0 as "8056ace5d0b6cd298fba2780df50a7ec233c33eeef" } from "<snip>/app/opengraph-image.tsx?__next_metadata_image_meta__"

I've pushed a (hopefully) clarifying comment.

new RegExp(WEBPACK_RESOURCE_QUERIES.metadataImageMeta),
],
},
resolve: {
mainFields: getMainField(compilerType, true),
conditionNames: reactServerCondition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
} from '../loaders/next-flight-client-entry-loader'

import { webpack } from 'next/dist/compiled/webpack/webpack'
import { stringify } from 'querystring'
import { parse, stringify } from 'querystring'
import path from 'path'
import { sources } from 'next/dist/compiled/webpack/webpack'
import {
Expand All @@ -13,7 +13,10 @@ import {
EntryTypes,
getEntryKey,
} from '../../../server/dev/on-demand-entry-handler'
import { WEBPACK_LAYERS } from '../../../lib/constants'
import {
WEBPACK_LAYERS,
WEBPACK_RESOURCE_QUERIES,
} from '../../../lib/constants'
import {
APP_CLIENT_INTERNALS,
BARREL_OPTIMIZATION_PREFIX,
Expand Down Expand Up @@ -41,6 +44,7 @@ import { PAGE_TYPES } from '../../../lib/page-types'
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
import { getAssumedSourceType } from '../loaders/next-flight-loader'
import { isAppRouteRoute } from '../../../lib/is-app-route-route'
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'

interface Options {
dev: boolean
Expand Down Expand Up @@ -296,10 +300,14 @@ export class FlightClientEntryPlugin {
compilation.moduleGraph
)) {
// Entry can be any user defined entry files such as layout, page, error, loading, etc.
const entryRequest = (
let entryRequest = (
connection.dependency as unknown as webpack.NormalModule
).request

if (entryRequest.endsWith(WEBPACK_RESOURCE_QUERIES.metadataRoute)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking: would be nice to have a helper to extract entryRequest and handle all cases, using let scares me that we might need to take care of these cases everywhere when we create them.

entryRequest = getMetadataRouteResource(entryRequest)
}

const { clientComponentImports, actionImports, cssImports } =
this.collectComponentInfoFromServerEntryDependency({
entryRequest,
Expand Down Expand Up @@ -332,10 +340,16 @@ export class FlightClientEntryPlugin {
: entryRequest

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

// For metadata routes, the entry name can be used as the bundle path,
// as it has been normalized already.
if (isMetadataRoute(bundlePath)) {
bundlePath = name
}

Object.assign(mergedCSSimports, cssImports)
clientEntriesToInject.push({
compiler,
Expand Down Expand Up @@ -1094,5 +1108,16 @@ function getModuleResource(mod: webpack.NormalModule): string {
if (mod.matchResource?.startsWith(BARREL_OPTIMIZATION_PREFIX)) {
modResource = mod.matchResource + ':' + modResource
}

if (mod.resource === `?${WEBPACK_RESOURCE_QUERIES.metadataRoute}`) {
return getMetadataRouteResource(mod.rawRequest)
}

return modResource
}

function getMetadataRouteResource(request: string): string {
const query = request.split('next-metadata-route-loader?')[1]

return parse(query).filePath as string
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
} from '../utils'
import type { ChunkGroup } from 'webpack'
import { encodeURIPath } from '../../../shared/lib/encode-uri-path'
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'
import type { ModuleInfo } from './flight-client-entry-plugin'

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

// We also need to create manifests for route handler entrypoints
// (excluding metadata route handlers) to enable `'use cache'`.
if (/\/route$/.test(entryName) && !isMetadataRoute(entryName)) {
// We also need to create manifests for route handler entrypoints to
// enable `'use cache'`.
if (/\/route$/.test(entryName)) {
manifestEntryFiles.push(entryName)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import picomatch from 'next/dist/compiled/picomatch'
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
import { getPageFilePath } from '../../entries'
import { resolveExternal } from '../../handle-externals'
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'

const PLUGIN_NAME = 'TraceEntryPointsPlugin'
export const TRACE_IGNORES = [
Expand Down Expand Up @@ -243,18 +242,15 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
)

if (entrypoint.name.startsWith('app/')) {
// Include the client reference manifest for pages and route handlers,
// excluding metadata route handlers.
const clientManifestsForEntrypoint = isMetadataRoute(entrypoint.name)
? null
: nodePath.join(
outputPath,
outputPrefix,
entrypoint.name.replace(/%5F/g, '_') +
'_' +
CLIENT_REFERENCE_MANIFEST +
'.js'
)
// include the client reference manifest
const clientManifestsForEntrypoint = nodePath.join(
outputPath,
outputPrefix,
entrypoint.name.replace(/%5F/g, '_') +
'_' +
CLIENT_REFERENCE_MANIFEST +
'.js'
)

if (clientManifestsForEntrypoint !== null) {
entryFiles.add(clientManifestsForEntrypoint)
Expand Down
7 changes: 1 addition & 6 deletions packages/next/src/build/webpack/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
ModuleGraph,
} from 'webpack'
import type { ModuleGraphConnection } from 'webpack'
import { isMetadataRoute } from '../../lib/metadata/is-metadata-route'

export function traverseModules(
compilation: Compilation,
Expand Down Expand Up @@ -48,11 +47,7 @@ export function forEachEntryModule(
) {
for (const [name, entry] of compilation.entries.entries()) {
// Skip for entries under pages/
if (
name.startsWith('pages/') ||
// Skip for metadata route handlers
(name.startsWith('app/') && isMetadataRoute(name))
) {
if (name.startsWith('pages/')) {
continue
}

Expand Down
6 changes: 1 addition & 5 deletions packages/next/src/server/load-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import { wait } from '../lib/wait'
import { setReferenceManifestsSingleton } from './app-render/encryption-utils'
import { createServerModuleMap } from './app-render/action-utils'
import type { DeepReadonly } from '../shared/lib/deep-readonly'
import { isMetadataRoute } from '../lib/metadata/is-metadata-route'
import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path'

export type ManifestItem = {
Expand Down Expand Up @@ -169,9 +168,6 @@ async function loadComponentsImpl<N = any>({
])
}

// Make sure to avoid loading the manifest for metadata route handlers.
const hasClientManifest = isAppPath && !isMetadataRoute(page)

// In dev mode we retry loading a manifest file to handle a race condition
// that can occur while app and pages are compiling at the same time, and the
// build-manifest is still being written to disk while an app path is
Expand Down Expand Up @@ -227,7 +223,7 @@ async function loadComponentsImpl<N = any>({
join(distDir, `${DYNAMIC_CSS_MANIFEST}.json`),
manifestLoadAttempts
).catch(() => undefined),
hasClientManifest
isAppPath
? tryLoadClientReferenceManifest(
join(
distDir,
Expand Down
38 changes: 38 additions & 0 deletions test/e2e/app-dir/use-cache-metadata-route-handler/app/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ImageResponse } from 'next/og'
import { setTimeout } from 'timers/promises'

export const size = { width: 32, height: 32 }
export const contentType = 'image/png'

async function fetchIconLetter() {
'use cache'

// Simulate I/O
await setTimeout(100)

return 'N'
}

export default async function Icon() {
const letter = await fetchIconLetter()

return new ImageResponse(
(
<div
style={{
fontSize: 24,
background: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
}}
>
{letter}
</div>
),
{ ...size }
)
}
12 changes: 12 additions & 0 deletions test/e2e/app-dir/use-cache-metadata-route-handler/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { MetadataRoute } from 'next'
import { getSentinelValue } from './sentinel'
import { setTimeout } from 'timers/promises'

export default async function manifest(): Promise<MetadataRoute.Manifest> {
'use cache'

// Simulate I/O
await setTimeout(100)

return { name: getSentinelValue() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ImageResponse } from 'next/og'

export const alt = 'About Acme'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

async function fetchPostData() {
'use cache'

return { title: 'Test', created: Date.now() }
}

export default async function Image() {
const post = await fetchPostData()

return new ImageResponse(
(
<div
style={{
fontSize: 48,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<h1>{post.title}</h1>
<p style={{ fontSize: 32 }}>
{new Date(post.created).toLocaleTimeString()}
</p>
</div>
),
size
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next'
import { getSentinelValue } from '../sentinel'
import { setTimeout } from 'timers/promises'

export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }]
}

export default async function sitemap({
id,
}: {
id: number
}): Promise<MetadataRoute.Sitemap> {
'use cache'

// Simulate I/O
await setTimeout(100)

return [{ url: `https://acme.com/${id}?sentinel=${getSentinelValue()}` }]
}
14 changes: 14 additions & 0 deletions test/e2e/app-dir/use-cache-metadata-route-handler/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { MetadataRoute } from 'next'
import { getSentinelValue } from './sentinel'
import { setTimeout } from 'timers/promises'

export default async function robots(): Promise<MetadataRoute.Robots> {
'use cache'

// Simulate I/O
await setTimeout(100)

return {
rules: { userAgent: '*', allow: `/${getSentinelValue()}` },
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const { PHASE_PRODUCTION_BUILD } = require('next/constants')

export function getSentinelValue() {
return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
? 'buildtime'
: 'runtime'
}
12 changes: 12 additions & 0 deletions test/e2e/app-dir/use-cache-metadata-route-handler/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { MetadataRoute } from 'next'
import { getSentinelValue } from './sentinel'
import { setTimeout } from 'timers/promises'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
'use cache'

// Simulate I/O
await setTimeout(100)

return [{ url: `https://acme.com?sentinel=${getSentinelValue()}` }]
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/use-cache-metadata-route-handler/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
dynamicIO: true,
},
}

module.exports = nextConfig
Loading
Loading