diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx
index 83ec9bb5127d4a..b749cb8824779d 100644
--- a/packages/next/src/lib/metadata/metadata.tsx
+++ b/packages/next/src/lib/metadata/metadata.tsx
@@ -36,6 +36,11 @@ import type { WorkStore } from '../../server/app-render/work-async-storage.exter
import { createServerSearchParamsForMetadata } from '../../server/request/search-params'
import { createServerPathnameForMetadata } from '../../server/request/pathname'
import { isPostpone } from '../../server/lib/router-utils/is-postpone'
+import { workUnitAsyncStorage } from '../../server/app-render/work-unit-async-storage.external'
+import {
+ RenderStage,
+ type StagedRenderingController,
+} from '../../server/app-render/staged-rendering'
import {
MetadataBoundary,
@@ -58,6 +63,7 @@ export function createMetadataComponents({
errorType,
workStore,
serveStreamingMetadata,
+ isRuntimePrefetchable,
}: {
tree: LoaderTree
pathname: string
@@ -67,6 +73,7 @@ export function createMetadataComponents({
errorType?: MetadataErrorType | 'redirect'
workStore: WorkStore
serveStreamingMetadata: boolean
+ isRuntimePrefetchable: boolean
}): {
Viewport: React.ComponentType
Metadata: React.ComponentType
@@ -74,7 +81,8 @@ export function createMetadataComponents({
} {
const searchParams = createServerSearchParamsForMetadata(
parsedQuery,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
const pathnameForMetadata = createServerPathnameForMetadata(
pathname,
@@ -82,11 +90,42 @@ export function createMetadataComponents({
)
async function Viewport() {
+ // Gate metadata to the correct render stage. If the page is not
+ // runtime-prefetchable, defer until the Static stage so that
+ // prefetchable segments get a head start.
+ if (!isRuntimePrefetchable) {
+ const workUnitStore = workUnitAsyncStorage.getStore()
+ if (workUnitStore) {
+ let stagedRendering: StagedRenderingController | null | undefined
+ switch (workUnitStore.type) {
+ case 'request':
+ case 'prerender-runtime':
+ stagedRendering = workUnitStore.stagedRendering
+ break
+ case 'prerender':
+ case 'prerender-client':
+ case 'validation-client':
+ case 'prerender-ppr':
+ case 'prerender-legacy':
+ case 'cache':
+ case 'private-cache':
+ case 'unstable-cache':
+ break
+ default:
+ workUnitStore satisfies never
+ }
+ if (stagedRendering) {
+ await stagedRendering.waitForStage(RenderStage.Static)
+ }
+ }
+ }
+
const tags = await getResolvedViewport(
tree,
searchParams,
getDynamicParamFromSegment,
workStore,
+ isRuntimePrefetchable,
errorType
).catch((viewportErr) => {
// When Legacy PPR is enabled viewport can reject with a Postpone type
@@ -100,7 +139,8 @@ export function createMetadataComponents({
tree,
searchParams,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
).catch(() => null)
}
// We're going to throw the error from the metadata outlet so we just render null here instead
@@ -120,6 +160,36 @@ export function createMetadataComponents({
}
async function Metadata() {
+ // Gate metadata to the correct render stage. If the page is not
+ // runtime-prefetchable, defer until the Static stage so that
+ // prefetchable segments get a head start.
+ if (!isRuntimePrefetchable) {
+ const workUnitStore = workUnitAsyncStorage.getStore()
+ if (workUnitStore) {
+ let stagedRendering: StagedRenderingController | null | undefined
+ switch (workUnitStore.type) {
+ case 'request':
+ case 'prerender-runtime':
+ stagedRendering = workUnitStore.stagedRendering
+ break
+ case 'prerender':
+ case 'prerender-client':
+ case 'validation-client':
+ case 'prerender-ppr':
+ case 'prerender-legacy':
+ case 'cache':
+ case 'private-cache':
+ case 'unstable-cache':
+ break
+ default:
+ workUnitStore satisfies never
+ }
+ if (stagedRendering) {
+ await stagedRendering.waitForStage(RenderStage.Static)
+ }
+ }
+ }
+
const tags = await getResolvedMetadata(
tree,
pathnameForMetadata,
@@ -127,6 +197,7 @@ export function createMetadataComponents({
getDynamicParamFromSegment,
metadataContext,
workStore,
+ isRuntimePrefetchable,
errorType
).catch((metadataErr) => {
// When Legacy PPR is enabled metadata can reject with a Postpone type
@@ -142,7 +213,8 @@ export function createMetadataComponents({
searchParams,
getDynamicParamFromSegment,
metadataContext,
- workStore
+ workStore,
+ isRuntimePrefetchable
).catch(() => null)
}
// We're going to throw the error from the metadata outlet so we just render null here instead
@@ -184,6 +256,7 @@ export function createMetadataComponents({
getDynamicParamFromSegment,
metadataContext,
workStore,
+ isRuntimePrefetchable,
errorType
),
getResolvedViewport(
@@ -191,6 +264,7 @@ export function createMetadataComponents({
searchParams,
getDynamicParamFromSegment,
workStore,
+ isRuntimePrefetchable,
errorType
),
]).then(() => null)
@@ -224,6 +298,7 @@ async function getResolvedMetadataImpl(
getDynamicParamFromSegment: GetDynamicParamFromSegment,
metadataContext: MetadataContext,
workStore: WorkStore,
+ isRuntimePrefetchable: boolean,
errorType?: MetadataErrorType | 'redirect'
): Promise {
const errorConvention = errorType === 'redirect' ? undefined : errorType
@@ -234,6 +309,7 @@ async function getResolvedMetadataImpl(
getDynamicParamFromSegment,
metadataContext,
workStore,
+ isRuntimePrefetchable,
errorConvention
)
}
@@ -245,7 +321,8 @@ async function getNotFoundMetadataImpl(
searchParams: Promise,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
metadataContext: MetadataContext,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const notFoundErrorConvention = 'not-found'
return renderMetadata(
@@ -255,6 +332,7 @@ async function getNotFoundMetadataImpl(
getDynamicParamFromSegment,
metadataContext,
workStore,
+ isRuntimePrefetchable,
notFoundErrorConvention
)
}
@@ -265,6 +343,7 @@ async function getResolvedViewportImpl(
searchParams: Promise,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
workStore: WorkStore,
+ isRuntimePrefetchable: boolean,
errorType?: MetadataErrorType | 'redirect'
): Promise {
const errorConvention = errorType === 'redirect' ? undefined : errorType
@@ -273,6 +352,7 @@ async function getResolvedViewportImpl(
searchParams,
getDynamicParamFromSegment,
workStore,
+ isRuntimePrefetchable,
errorConvention
)
}
@@ -282,7 +362,8 @@ async function getNotFoundViewportImpl(
tree: LoaderTree,
searchParams: Promise,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const notFoundErrorConvention = 'not-found'
return renderViewport(
@@ -290,6 +371,7 @@ async function getNotFoundViewportImpl(
searchParams,
getDynamicParamFromSegment,
workStore,
+ isRuntimePrefetchable,
notFoundErrorConvention
)
}
@@ -301,6 +383,7 @@ async function renderMetadata(
getDynamicParamFromSegment: GetDynamicParamFromSegment,
metadataContext: MetadataContext,
workStore: WorkStore,
+ isRuntimePrefetchable: boolean,
errorConvention?: MetadataErrorType
) {
const resolvedMetadata = await resolveMetadata(
@@ -310,7 +393,8 @@ async function renderMetadata(
errorConvention,
getDynamicParamFromSegment,
workStore,
- metadataContext
+ metadataContext,
+ isRuntimePrefetchable
)
const elements: Array =
createMetadataElements(resolvedMetadata)
@@ -328,6 +412,7 @@ async function renderViewport(
searchParams: Promise,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
workStore: WorkStore,
+ isRuntimePrefetchable: boolean,
errorConvention?: MetadataErrorType
) {
const resolvedViewport = await resolveViewport(
@@ -335,7 +420,8 @@ async function renderViewport(
searchParams,
errorConvention,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
const elements: Array =
diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts
index c379427ae41168..10414a16fe2f6c 100644
--- a/packages/next/src/lib/metadata/resolve-metadata.ts
+++ b/packages/next/src/lib/metadata/resolve-metadata.ts
@@ -709,7 +709,8 @@ const resolveMetadataItems = cache(async function (
searchParams: Promise,
errorConvention: MetadataErrorType | undefined,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
) {
const parentParams = {}
const metadataItems: MetadataItems = []
@@ -724,7 +725,8 @@ const resolveMetadataItems = cache(async function (
errorConvention,
errorMetadataItem,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
})
@@ -738,7 +740,8 @@ async function resolveMetadataItemsImpl(
errorConvention: MetadataErrorType | undefined,
errorMetadataItem: MetadataItems[number],
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const [segment, parallelRoutes, { page }] = tree
const currentTreePrefix =
@@ -758,7 +761,11 @@ async function resolveMetadataItemsImpl(
}
}
- const params = createServerParamsForMetadata(currentParams, workStore)
+ const params = createServerParamsForMetadata(
+ currentParams,
+ workStore,
+ isRuntimePrefetchable
+ )
const props: SegmentProps = isPage ? { params, searchParams } : { params }
await collectMetadata({
@@ -784,7 +791,8 @@ async function resolveMetadataItemsImpl(
errorConvention,
errorMetadataItem,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
}
@@ -803,7 +811,8 @@ const resolveViewportItems = cache(async function (
searchParams: Promise,
errorConvention: MetadataErrorType | undefined,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
) {
const parentParams = {}
const viewportItems: ViewportItems = []
@@ -820,7 +829,8 @@ const resolveViewportItems = cache(async function (
errorConvention,
errorViewportItemRef,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
})
@@ -834,7 +844,8 @@ async function resolveViewportItemsImpl(
errorConvention: MetadataErrorType | undefined,
errorViewportItemRef: ErrorViewportItemRef,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const [segment, parallelRoutes, { page }] = tree
const currentTreePrefix =
@@ -854,7 +865,11 @@ async function resolveViewportItemsImpl(
}
}
- const params = createServerParamsForMetadata(currentParams, workStore)
+ const params = createServerParamsForMetadata(
+ currentParams,
+ workStore,
+ isRuntimePrefetchable
+ )
let layerProps: LayoutProps | PageProps
if (isPage) {
@@ -891,7 +906,8 @@ async function resolveViewportItemsImpl(
errorConvention,
errorViewportItemRef,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
}
@@ -1261,14 +1277,16 @@ export async function resolveMetadata(
errorConvention: MetadataErrorType | undefined,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
workStore: WorkStore,
- metadataContext: MetadataContext
+ metadataContext: MetadataContext,
+ isRuntimePrefetchable: boolean
): Promise {
const metadataItems = await resolveMetadataItems(
tree,
searchParams,
errorConvention,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
return accumulateMetadata(
workStore.route,
@@ -1284,14 +1302,16 @@ export async function resolveViewport(
searchParams: Promise,
errorConvention: MetadataErrorType | undefined,
getDynamicParamFromSegment: GetDynamicParamFromSegment,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const viewportItems = await resolveViewportItems(
tree,
searchParams,
errorConvention,
getDynamicParamFromSegment,
- workStore
+ workStore,
+ isRuntimePrefetchable
)
return accumulateViewport(viewportItems)
}
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index e6967bda4bc1d2..6d7e867e3b82ad 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -223,6 +223,7 @@ import { ImageConfigContext } from '../../shared/lib/image-config-context.shared
import { imageConfigDefault } from '../../shared/lib/image-config'
import { RenderStage, StagedRenderingController } from './staged-rendering'
import {
+ anySegmentHasRuntimePrefetchEnabled,
isPageAllowedToBlock,
anySegmentNeedsInstantValidation,
} from './instant-validation/instant-config'
@@ -539,6 +540,8 @@ async function generateDynamicRSCPayload(
!options?.actionResult && // Only for navigations
(await anySegmentNeedsInstantValidation(loaderTree))
+ const metadataIsRuntimePrefetchable =
+ await anySegmentHasRuntimePrefetchEnabled(loaderTree)
const { Viewport, Metadata, MetadataOutlet } = createMetadataComponents({
tree: loaderTree,
parsedQuery: query,
@@ -547,6 +550,7 @@ async function generateDynamicRSCPayload(
getDynamicParamFromSegment,
workStore,
serveStreamingMetadata,
+ isRuntimePrefetchable: metadataIsRuntimePrefetchable,
})
const rscHead = createElement(
@@ -1531,6 +1535,8 @@ async function getRSCPayload(
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const hasGlobalNotFound = !!tree[2]['global-not-found']
+ const metadataIsRuntimePrefetchable =
+ await anySegmentHasRuntimePrefetchEnabled(tree)
const { Viewport, Metadata, MetadataOutlet } = createMetadataComponents({
tree,
// When it's using global-not-found, metadata errorType is undefined, which will retrieve the
@@ -1545,6 +1551,7 @@ async function getRSCPayload(
getDynamicParamFromSegment,
workStore,
serveStreamingMetadata,
+ isRuntimePrefetchable: metadataIsRuntimePrefetchable,
})
const preloadCallbacks: PreloadCallbacks = []
@@ -1657,6 +1664,8 @@ async function getErrorRSCPayload(
} = ctx
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
+ const metadataIsRuntimePrefetchable =
+ await anySegmentHasRuntimePrefetchEnabled(tree)
const { Viewport, Metadata } = createMetadataComponents({
tree,
parsedQuery: query,
@@ -1666,6 +1675,7 @@ async function getErrorRSCPayload(
getDynamicParamFromSegment,
workStore,
serveStreamingMetadata: serveStreamingMetadata,
+ isRuntimePrefetchable: metadataIsRuntimePrefetchable,
})
const initialHead = createElement(
diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts
index 59cc5de188292f..af1a42f8ea3196 100644
--- a/packages/next/src/server/request/params.ts
+++ b/packages/next/src/server/request/params.ts
@@ -101,19 +101,18 @@ export function createParamsFromClient(
}
// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component
-// TODO: metadata should inherit the runtime prefetchability of the page segment
-const metadataIsRuntimePrefetchable = false
export type CreateServerParamsForMetadata = typeof createServerParamsForMetadata
export function createServerParamsForMetadata(
underlyingParams: Params,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const metadataVaryParamsAccumulator = getMetadataVaryParamsAccumulator()
return createServerParamsForServerSegment(
underlyingParams,
workStore,
metadataVaryParamsAccumulator,
- metadataIsRuntimePrefetchable
+ isRuntimePrefetchable
)
}
diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts
index 46d4774761aed0..28ebda48358690 100644
--- a/packages/next/src/server/request/search-params.ts
+++ b/packages/next/src/server/request/search-params.ts
@@ -83,18 +83,17 @@ export function createSearchParamsFromClient(
}
// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component
-// TODO: metadata should inherit the runtime prefetchability of the page segment
-const metadataIsRuntimePrefetchable = false
export function createServerSearchParamsForMetadata(
underlyingSearchParams: SearchParams,
- workStore: WorkStore
+ workStore: WorkStore,
+ isRuntimePrefetchable: boolean
): Promise {
const metadataVaryParamsAccumulator = getMetadataVaryParamsAccumulator()
return createServerSearchParamsForServerPage(
underlyingSearchParams,
workStore,
metadataVaryParamsAccumulator,
- metadataIsRuntimePrefetchable
+ isRuntimePrefetchable
)
}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx
index a014750ae22b3a..0c14e2050499c7 100644
--- a/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx
@@ -47,6 +47,18 @@ export default async function Page() {
+
+
+
+
+
+
+
+
+
+
+
+
Static
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata/page.tsx
new file mode 100644
index 00000000000000..a2981caf6eac90
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata/page.tsx
@@ -0,0 +1,30 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+export const unstable_instant = {
+ prefetch: 'runtime',
+ samples: [{ cookies: [] }],
+}
+
+export async function generateMetadata() {
+ await cookies()
+ const now = Date.now()
+ return {
+ title: `Sync IO in metadata: ${now}`,
+ }
+}
+
+async function Runtime() {
+ await cookies()
+ return Runtime content
+}
+
+export default function Page() {
+ return (
+
+ Loading...
}>
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/layout.tsx
new file mode 100644
index 00000000000000..51f9d9ef1a0b92
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/layout.tsx
@@ -0,0 +1,19 @@
+import { cookies } from 'next/headers'
+
+// This layout does NOT have runtime prefetch itself, but the child page
+// does. Since metadata belongs to the Page, the sync IO heuristic for
+// generateMetadata uses the Page's prefetchability. Because the child
+// page has runtime prefetch enabled, sync IO in this layout's
+// generateMetadata should error.
+
+export async function generateMetadata() {
+ await cookies()
+ const now = Date.now()
+ return {
+ title: `Layout metadata with sync IO: ${now}`,
+ }
+}
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children}
+}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/page.tsx
new file mode 100644
index 00000000000000..0b90a7323db920
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/page.tsx
@@ -0,0 +1,22 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+export const unstable_instant = {
+ prefetch: 'runtime',
+ samples: [{ cookies: [] }],
+}
+
+async function Runtime() {
+ await cookies()
+ return Runtime content
+}
+
+export default function Page() {
+ return (
+
+ Loading...}>
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-generate-metadata-static-page/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-generate-metadata-static-page/page.tsx
new file mode 100644
index 00000000000000..78f1d4ee267ad6
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-generate-metadata-static-page/page.tsx
@@ -0,0 +1,28 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+// No unstable_instant — this page is NOT runtime-prefetchable.
+// Sync IO in generateMetadata should be allowed.
+
+export async function generateMetadata() {
+ await cookies()
+ const now = Date.now()
+ return {
+ title: `Sync IO in metadata: ${now}`,
+ }
+}
+
+async function Runtime() {
+ await cookies()
+ return Runtime content
+}
+
+export default function Page() {
+ return (
+
+ Loading...}>
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/layout.tsx
new file mode 100644
index 00000000000000..98cdc5d1cd9daa
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/layout.tsx
@@ -0,0 +1,17 @@
+import { cookies } from 'next/headers'
+
+// This layout does NOT have runtime prefetch and neither does the child
+// page. Since no segment has runtime prefetch enabled, sync IO in
+// generateMetadata should be allowed.
+
+export async function generateMetadata() {
+ await cookies()
+ const now = Date.now()
+ return {
+ title: `Layout metadata with sync IO: ${now}`,
+ }
+}
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children}
+}
diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/page.tsx
new file mode 100644
index 00000000000000..cd69800a2bbd52
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page/page.tsx
@@ -0,0 +1,19 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+// No unstable_instant — this page is NOT runtime-prefetchable.
+
+async function Runtime() {
+ await cookies()
+ return Runtime content
+}
+
+export default function Page() {
+ return (
+
+ Loading...}>
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/instant-validation/instant-validation.test.ts b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
index e6208c300a8cd4..dfedf3d00cd54d 100644
--- a/test/e2e/app-dir/instant-validation/instant-validation.test.ts
+++ b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
@@ -482,6 +482,75 @@ describe('instant validation', () => {
await waitForNoErrorToast(browser)
})
+ it('invalid - runtime prefetch - sync IO in generateMetadata', async () => {
+ // The page has runtime prefetch enabled. generateMetadata uses
+ // cookies() then Date.now(). Since metadata belongs to the Page
+ // and the Page is runtime-prefetchable, this should error.
+ const browser = await navigateTo(
+ '/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata'
+ )
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "description": "Route "/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata" used \`Date.now()\` before accessing either uncached data (e.g. \`fetch()\`) or awaiting \`connection()\`. When configured for Runtime prefetching, accessing the current time in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-runtime-current-time",
+ "environmentLabel": "Server",
+ "label": "Console Error",
+ "source": "app/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata/page.tsx (11:20) @ Module.generateMetadata
+ > 11 | const now = Date.now()
+ | ^",
+ "stack": [
+ "Module.generateMetadata app/suspense-in-root/runtime/invalid-sync-io-in-generate-metadata/page.tsx (11:20)",
+ "Next.MetadataOutlet ",
+ ],
+ }
+ `)
+ })
+
+ it('valid - runtime prefetch - sync IO in generateMetadata on a static page is allowed', async () => {
+ // The page does NOT have runtime prefetch. generateMetadata uses
+ // cookies() then Date.now(). Since no segment is runtime-prefetchable,
+ // sync IO in generateMetadata should be allowed.
+ const browser = await navigateTo(
+ '/suspense-in-root/runtime/valid-sync-io-in-generate-metadata-static-page'
+ )
+ await waitForNoErrorToast(browser)
+ })
+
+ it('invalid - runtime prefetch - sync IO in layout generateMetadata when page is prefetchable', async () => {
+ // The layout has generateMetadata with sync IO after cookies().
+ // The layout itself does NOT have runtime prefetch, but the child
+ // page does. Since metadata belongs to the Page, and the Page is
+ // runtime-prefetchable, sync IO in the layout's generateMetadata
+ // should error.
+ const browser = await navigateTo(
+ '/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata'
+ )
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "description": "Route "/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata" used \`Date.now()\` before accessing either uncached data (e.g. \`fetch()\`) or awaiting \`connection()\`. When configured for Runtime prefetching, accessing the current time in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-runtime-current-time",
+ "environmentLabel": "Server",
+ "label": "Console Error",
+ "source": "app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/layout.tsx (11:20) @ Module.generateMetadata
+ > 11 | const now = Date.now()
+ | ^",
+ "stack": [
+ "Module.generateMetadata app/suspense-in-root/runtime/invalid-sync-io-in-layout-generate-metadata/layout.tsx (11:20)",
+ "Next.MetadataOutlet ",
+ ],
+ }
+ `)
+ })
+
+ it('valid - runtime prefetch - sync IO in layout generateMetadata when page is NOT prefetchable', async () => {
+ // The layout has generateMetadata with sync IO after cookies().
+ // Neither the layout nor the page has runtime prefetch. Since no
+ // segment is runtime-prefetchable, sync IO in generateMetadata
+ // should be allowed.
+ const browser = await navigateTo(
+ '/suspense-in-root/runtime/valid-sync-io-in-layout-generate-metadata-static-page'
+ )
+ await waitForNoErrorToast(browser)
+ })
+
it('invalid - missing suspense around dynamic (with loading.js)', async () => {
const browser = await navigateTo(
'/suspense-in-root/static/invalid-only-loading-around-dynamic'