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'