diff --git a/.changeset/tricky-planes-worry.md b/.changeset/tricky-planes-worry.md
new file mode 100644
index 000000000000..b8d38dfa9237
--- /dev/null
+++ b/.changeset/tricky-planes-worry.md
@@ -0,0 +1,5 @@
+---
+"next": patch
+---
+
+[dynamicIO] Avoid timeout errors with dynamic params in `"use cache"`
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2ab01fe8b8ee..b195325c3742 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -59,6 +59,7 @@
// singleton modules should always use "*.external" instead of "*-instance"
"packages/next/src/server/app-render/action-async-storage-instance.ts",
"packages/next/src/server/app-render/after-task-async-storage-instance.ts",
+ "packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts",
"packages/next/src/server/app-render/work-async-storage-instance.ts",
"packages/next/src/server/app-render/work-unit-async-storage-instance.ts",
"packages/next/src/client/components/segment-cache-impl/*"
diff --git a/packages/next/errors.json b/packages/next/errors.json
index dc824d03e7e2..e2b659778f99 100644
--- a/packages/next/errors.json
+++ b/packages/next/errors.json
@@ -682,5 +682,7 @@
"681": "Dynamic imports should not be instrumented in the edge runtime, because `dynamicIO` doesn't support it",
"682": "\\`experimental.ppr\\` can not be \\`%s\\` when \\`experimental.dynamicIO\\` is \\`true\\`. PPR is implicitly enabled when Dynamic IO is enabled.",
"683": "The \\`compiler.define\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured.",
- "684": "The \\`compiler.defineServer\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured."
+ "684": "The \\`compiler.defineServer\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured.",
+ "685": "Accessed fallback \\`params\\` during prerendering.",
+ "686": "Expected clientReferenceManifest to be defined."
}
diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts
index 8e73e892b2e4..f352c723fd8d 100644
--- a/packages/next/src/build/index.ts
+++ b/packages/next/src/build/index.ts
@@ -2683,8 +2683,7 @@ export default async function build(
_isDynamicError: isDynamicError,
_isAppDir: true,
_isRoutePPREnabled: isRoutePPREnabled,
- _doNotThrowOnEmptyStaticShell:
- !route.throwOnEmptyStaticShell,
+ _allowEmptyStaticShell: !route.throwOnEmptyStaticShell,
}
})
})
diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts
index 1345cdbbc442..609b7a21651f 100644
--- a/packages/next/src/build/webpack-config.ts
+++ b/packages/next/src/build/webpack-config.ts
@@ -133,7 +133,7 @@ const browserNonTranspileModules = [
const precompileRegex = /[\\/]next[\\/]dist[\\/]compiled[\\/]/
const asyncStoragesRegex =
- /next[\\/]dist[\\/](esm[\\/])?server[\\/]app-render[\\/](work-async-storage|action-async-storage|work-unit-async-storage)/
+ /next[\\/]dist[\\/](esm[\\/])?server[\\/]app-render[\\/](work-async-storage|action-async-storage|dynamic-access-async-storage|work-unit-async-storage)/
// Support for NODE_PATH
const nodePathList = (process.env.NODE_PATH || '')
diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts
index 2aa6a4e48459..ee1acdee932f 100644
--- a/packages/next/src/export/worker.ts
+++ b/packages/next/src/export/worker.ts
@@ -104,9 +104,9 @@ async function exportPageImpl(
// the renderOpts.
_isRoutePPREnabled: isRoutePPREnabled,
- // Configure the rendering of the page not to throw if an empty static shell
- // is generated while rendering using PPR.
- _doNotThrowOnEmptyStaticShell: doNotThrowOnEmptyStaticShell = false,
+ // Configure the rendering of the page to allow that an empty static shell
+ // is generated while rendering using PPR and Dynamic IO.
+ _allowEmptyStaticShell: allowEmptyStaticShell = false,
// Pull the original query out.
query: originalQuery = {},
@@ -266,7 +266,7 @@ async function exportPageImpl(
// If it's static, then it won't affect anything.
// If it's dynamic, then it can be handled when request hits the route.
serveStreamingMetadata: true,
- doNotThrowOnEmptyStaticShell,
+ allowEmptyStaticShell,
experimental: {
...input.renderOpts.experimental,
isRoutePPREnabled,
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index dd4803930bdb..9d8c2f12a562 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -190,6 +190,7 @@ import {
trackPendingImport,
trackPendingModules,
} from './module-loading/track-module-loading.external'
+import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors'
export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
@@ -665,7 +666,9 @@ async function warmupDevRender(
workStore,
} = ctx
- if (!renderOpts.dev) {
+ const { dev, onInstrumentationRequestError } = renderOpts
+
+ if (!dev) {
throw new InvariantError(
'generateDynamicFlightRenderResult should never be called in `next start` mode.'
)
@@ -677,7 +680,7 @@ async function warmupDevRender(
)
function onFlightDataRenderError(err: DigestedError) {
- return renderOpts.onInstrumentationRequestError?.(
+ return onInstrumentationRequestError?.(
err,
req,
createErrorContext(ctx, 'react-server-components-payload')
@@ -1022,6 +1025,16 @@ async function getErrorRSCPayload(
} satisfies InitialRSCPayload
}
+function assertClientReferenceManifest(
+ clientReferenceManifest: RenderOpts['clientReferenceManifest']
+): asserts clientReferenceManifest is NonNullable<
+ RenderOpts['clientReferenceManifest']
+> {
+ if (!clientReferenceManifest) {
+ throw new InvariantError('Expected clientReferenceManifest to be defined.')
+ }
+}
+
// This component must run in an SSR context. It will render the RSC root component
function App({
reactServerStream,
@@ -1183,6 +1196,7 @@ async function renderToHTMLOrFlightImpl(
const requestTimestamp = Date.now()
const {
+ clientReferenceManifest,
serverActionsManifest,
ComponentMod,
nextFontManifest,
@@ -1274,8 +1288,7 @@ async function renderToHTMLOrFlightImpl(
const appUsingSizeAdjustment = !!nextFontManifest?.appUsingSizeAdjust
- // TODO: fix this typescript
- const clientReferenceManifest = renderOpts.clientReferenceManifest!
+ assertClientReferenceManifest(clientReferenceManifest)
const serverModuleMap = createServerModuleMap({ serverActionsManifest })
@@ -1729,69 +1742,86 @@ async function renderToStream(
formState: any,
postponedState: PostponedState | null
): Promise> {
- const renderOpts = ctx.renderOpts
- const ComponentMod = renderOpts.ComponentMod
- // TODO: fix this typescript
- const clientReferenceManifest = renderOpts.clientReferenceManifest!
+ const { assetPrefix, nonce, pagePath, renderOpts } = ctx
+
+ const {
+ basePath,
+ botType,
+ buildManifest,
+ clientReferenceManifest,
+ ComponentMod,
+ crossOrigin,
+ dev = false,
+ experimental,
+ nextExport = false,
+ onInstrumentationRequestError,
+ page,
+ reactMaxHeadersLength,
+ shouldWaitOnAllReady,
+ subresourceIntegrityManifest,
+ supportsDynamicResponse,
+ } = renderOpts
+
+ assertClientReferenceManifest(clientReferenceManifest)
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
createServerInsertedHTML()
const { ServerInsertedMetadataProvider, getServerInsertedMetadata } =
- createServerInsertedMetadata(ctx.nonce)
+ createServerInsertedMetadata(nonce)
const tracingMetadata = getTracedMetadata(
getTracer().getTracePropagationData(),
- renderOpts.experimental.clientTraceMetadata
+ experimental.clientTraceMetadata
)
const polyfills: JSX.IntrinsicElements['script'][] =
- renderOpts.buildManifest.polyfillFiles
+ buildManifest.polyfillFiles
.filter(
(polyfill) =>
polyfill.endsWith('.js') && !polyfill.endsWith('.module.js')
)
.map((polyfill) => ({
- src: `${ctx.assetPrefix}/_next/${polyfill}${getAssetQueryString(
+ src: `${assetPrefix}/_next/${polyfill}${getAssetQueryString(
ctx,
false
)}`,
- integrity: renderOpts.subresourceIntegrityManifest?.[polyfill],
- crossOrigin: renderOpts.crossOrigin,
+ integrity: subresourceIntegrityManifest?.[polyfill],
+ crossOrigin,
noModule: true,
- nonce: ctx.nonce,
+ nonce,
}))
const [preinitScripts, bootstrapScript] = getRequiredScripts(
- renderOpts.buildManifest,
+ buildManifest,
// Why is assetPrefix optional on renderOpts?
// @TODO make it default empty string on renderOpts and get rid of it from ctx
- ctx.assetPrefix,
- renderOpts.crossOrigin,
- renderOpts.subresourceIntegrityManifest,
+ assetPrefix,
+ crossOrigin,
+ subresourceIntegrityManifest,
getAssetQueryString(ctx, true),
- ctx.nonce,
- renderOpts.page
+ nonce,
+ page
)
const reactServerErrorsByDigest: Map = new Map()
const silenceLogger = false
function onHTMLRenderRSCError(err: DigestedError) {
- return renderOpts.onInstrumentationRequestError?.(
+ return onInstrumentationRequestError?.(
err,
req,
createErrorContext(ctx, 'react-server-components')
)
}
const serverComponentsErrorHandler = createHTMLReactServerErrorHandler(
- !!renderOpts.dev,
- !!renderOpts.nextExport,
+ dev,
+ nextExport,
reactServerErrorsByDigest,
silenceLogger,
onHTMLRenderRSCError
)
function onHTMLRenderSSRError(err: DigestedError) {
- return renderOpts.onInstrumentationRequestError?.(
+ return onInstrumentationRequestError?.(
err,
req,
createErrorContext(ctx, 'server-rendering')
@@ -1800,8 +1830,8 @@ async function renderToStream(
const allCapturedErrors: Array = []
const htmlRendererErrorHandler = createHTMLErrorHandler(
- !!renderOpts.dev,
- !!renderOpts.nextExport,
+ dev,
+ nextExport,
reactServerErrorsByDigest,
allCapturedErrors,
silenceLogger,
@@ -1816,13 +1846,13 @@ async function renderToStream(
try {
if (
// We only want this behavior when running `next dev`
- renderOpts.dev &&
+ dev &&
// We only want this behavior when we have React's dev builds available
process.env.NODE_ENV === 'development' &&
// Edge routes never prerender so we don't have a Prerender environment for anything in edge runtime
process.env.NEXT_RUNTIME !== 'edge' &&
// We only have a Prerender environment for projects opted into dynamicIO
- renderOpts.experimental.dynamicIO
+ experimental.dynamicIO
) {
// This is a dynamic render. We don't do dynamic tracking because we're not prerendering
const RSCPayload: InitialRSCPayload & {
@@ -1911,7 +1941,7 @@ async function renderToStream(
// in the static prelude.
const inlinedReactServerDataStream = createInlinedDataReadableStream(
reactServerResult.tee(),
- ctx.nonce,
+ nonce,
formState
)
@@ -1935,27 +1965,24 @@ async function renderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- nonce={ctx.nonce}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ nonce={nonce}
+ gracefullyDegrade={!!botType}
/>,
postponed,
- {
- onError: htmlRendererErrorHandler,
- nonce: ctx.nonce,
- }
+ { onError: htmlRendererErrorHandler, nonce }
)
const getServerInsertedHTML = makeGetServerInsertedHTML({
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
})
return await continueDynamicHTMLResume(htmlStream, {
inlinedDataStream: createInlinedDataReadableStream(
reactServerResult.consume(),
- ctx.nonce,
+ nonce,
formState
),
getServerInsertedHTML,
@@ -1977,18 +2004,18 @@ async function renderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
- nonce={ctx.nonce}
+ gracefullyDegrade={!!botType}
+ nonce={nonce}
/>,
{
onError: htmlRendererErrorHandler,
- nonce: ctx.nonce,
+ nonce,
onHeaders: (headers: Headers) => {
headers.forEach((value, key) => {
appendHeader(key, value)
})
},
- maxHeadersLength: renderOpts.reactMaxHeadersLength,
+ maxHeadersLength: reactMaxHeadersLength,
bootstrapScripts: [bootstrapScript],
formState,
}
@@ -1998,7 +2025,7 @@ async function renderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
})
/**
@@ -2019,20 +2046,18 @@ async function renderToStream(
* coalescing, and ISR continue working as intended.
*/
const generateStaticHTML =
- renderOpts.supportsDynamicResponse !== true ||
- !!renderOpts.shouldWaitOnAllReady
+ supportsDynamicResponse !== true || !!shouldWaitOnAllReady
- const validateRootLayout = renderOpts.dev
return await continueFizzStream(htmlStream, {
inlinedDataStream: createInlinedDataReadableStream(
reactServerResult.consume(),
- ctx.nonce,
+ nonce,
formState
),
isStaticGeneration: generateStaticHTML,
getServerInsertedHTML,
getServerInsertedMetadata,
- validateRootLayout,
+ validateRootLayout: dev,
})
} catch (err) {
if (
@@ -2055,7 +2080,7 @@ async function renderToStream(
if (shouldBailoutToCSR) {
const stack = getStackWithoutErrorMessage(err)
error(
- `${err.reason} should be wrapped in a suspense boundary at page "${ctx.pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout\n${stack}`
+ `${err.reason} should be wrapped in a suspense boundary at page "${pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout\n${stack}`
)
throw err
@@ -2070,10 +2095,7 @@ async function renderToStream(
errorType = 'redirect'
res.statusCode = getRedirectStatusCodeFromError(err)
- const redirectUrl = addPathPrefix(
- getURLFromRedirectError(err),
- renderOpts.basePath
- )
+ const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath)
// If there were mutable cookies set, we need to set them on the
// response.
@@ -2088,12 +2110,12 @@ async function renderToStream(
}
const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts(
- renderOpts.buildManifest,
- ctx.assetPrefix,
- renderOpts.crossOrigin,
- renderOpts.subresourceIntegrityManifest,
+ buildManifest,
+ assetPrefix,
+ crossOrigin,
+ subresourceIntegrityManifest,
getAssetQueryString(ctx, false),
- ctx.nonce,
+ nonce,
'/_not-found/page'
)
@@ -2135,12 +2157,12 @@ async function renderToStream(
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
preinitScripts={errorPreinitScripts}
clientReferenceManifest={clientReferenceManifest}
- gracefullyDegrade={!!ctx.renderOpts.botType}
- nonce={ctx.nonce}
+ gracefullyDegrade={!!botType}
+ nonce={nonce}
/>
),
streamOptions: {
- nonce: ctx.nonce,
+ nonce,
// Include hydration scripts in the HTML
bootstrapScripts: [errorBootstrapScript],
formState,
@@ -2165,16 +2187,14 @@ async function renderToStream(
* coalescing, and ISR continue working as intended.
*/
const generateStaticHTML =
- renderOpts.supportsDynamicResponse !== true ||
- !!renderOpts.shouldWaitOnAllReady
- const validateRootLayout = renderOpts.dev
+ supportsDynamicResponse !== true || !!shouldWaitOnAllReady
return await continueFizzStream(fizzStream, {
inlinedDataStream: createInlinedDataReadableStream(
// This is intentionally using the readable datastream from the
// main render rather than the flight data from the error page
// render
reactServerResult.consume(),
- ctx.nonce,
+ nonce,
formState
),
isStaticGeneration: generateStaticHTML,
@@ -2182,11 +2202,11 @@ async function renderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: [],
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
}),
getServerInsertedMetadata,
- validateRootLayout,
+ validateRootLayout: dev,
})
} catch (finalErr: any) {
if (
@@ -2218,10 +2238,20 @@ async function spawnDynamicValidationInDev(
clientReferenceManifest: NonNullable,
requestStore: RequestStore
): Promise {
- const { componentMod: ComponentMod, implicitTags, workStore } = ctx
+ const {
+ componentMod: ComponentMod,
+ getDynamicParamFromSegment,
+ implicitTags,
+ nonce,
+ renderOpts,
+ workStore,
+ } = ctx
+
+ const { botType } = renderOpts
+
const rootParams = getRootParams(
ComponentMod.tree,
- ctx.getDynamicParamFromSegment
+ getDynamicParamFromSegment
)
const hmrRefreshHash = requestStore.cookies.get(
@@ -2334,7 +2364,6 @@ async function spawnDynamicValidationInDev(
}
}
- const nonce = '1'
const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce)
@@ -2350,7 +2379,7 @@ async function spawnDynamicValidationInDev(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
{
@@ -2496,8 +2525,8 @@ async function spawnDynamicValidationInDev(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
- nonce={ctx.nonce}
+ gracefullyDegrade={!!botType}
+ nonce={nonce}
/>,
{
signal: finalClientController.signal,
@@ -2613,10 +2642,27 @@ async function prerenderToStream(
workStore,
} = ctx
+ const {
+ allowEmptyStaticShell = false,
+ basePath,
+ botType,
+ buildManifest,
+ clientReferenceManifest,
+ ComponentMod,
+ crossOrigin,
+ dev = false,
+ experimental,
+ isDebugDynamicAccesses,
+ nextExport = false,
+ onInstrumentationRequestError,
+ page,
+ reactMaxHeadersLength,
+ subresourceIntegrityManifest,
+ } = renderOpts
+
+ assertClientReferenceManifest(clientReferenceManifest)
+
const rootParams = getRootParams(tree, getDynamicParamFromSegment)
- const ComponentMod = renderOpts.ComponentMod
- // TODO: fix this typescript
- const clientReferenceManifest = renderOpts.clientReferenceManifest!
const fallbackRouteParams = workStore.fallbackRouteParams
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
@@ -2626,11 +2672,11 @@ async function prerenderToStream(
const tracingMetadata = getTracedMetadata(
getTracer().getTracePropagationData(),
- renderOpts.experimental.clientTraceMetadata
+ experimental.clientTraceMetadata
)
const polyfills: JSX.IntrinsicElements['script'][] =
- renderOpts.buildManifest.polyfillFiles
+ buildManifest.polyfillFiles
.filter(
(polyfill) =>
polyfill.endsWith('.js') && !polyfill.endsWith('.module.js')
@@ -2640,44 +2686,44 @@ async function prerenderToStream(
ctx,
false
)}`,
- integrity: renderOpts.subresourceIntegrityManifest?.[polyfill],
- crossOrigin: renderOpts.crossOrigin,
+ integrity: subresourceIntegrityManifest?.[polyfill],
+ crossOrigin,
noModule: true,
- nonce: nonce,
+ nonce,
}))
const [preinitScripts, bootstrapScript] = getRequiredScripts(
- renderOpts.buildManifest,
+ buildManifest,
// Why is assetPrefix optional on renderOpts?
// @TODO make it default empty string on renderOpts and get rid of it from ctx
assetPrefix,
- renderOpts.crossOrigin,
- renderOpts.subresourceIntegrityManifest,
+ crossOrigin,
+ subresourceIntegrityManifest,
getAssetQueryString(ctx, true),
nonce,
- renderOpts.page
+ page
)
const reactServerErrorsByDigest: Map = new Map()
// We don't report errors during prerendering through our instrumentation hooks
- const silenceLogger = !!renderOpts.experimental.isRoutePPREnabled
+ const silenceLogger = !!experimental.isRoutePPREnabled
function onHTMLRenderRSCError(err: DigestedError) {
- return renderOpts.onInstrumentationRequestError?.(
+ return onInstrumentationRequestError?.(
err,
req,
createErrorContext(ctx, 'react-server-components')
)
}
const serverComponentsErrorHandler = createHTMLReactServerErrorHandler(
- !!renderOpts.dev,
- !!renderOpts.nextExport,
+ dev,
+ nextExport,
reactServerErrorsByDigest,
silenceLogger,
onHTMLRenderRSCError
)
function onHTMLRenderSSRError(err: DigestedError) {
- return renderOpts.onInstrumentationRequestError?.(
+ return onInstrumentationRequestError?.(
err,
req,
createErrorContext(ctx, 'server-rendering')
@@ -2685,8 +2731,8 @@ async function prerenderToStream(
}
const allCapturedErrors: Array = []
const htmlRendererErrorHandler = createHTMLErrorHandler(
- !!renderOpts.dev,
- !!renderOpts.nextExport,
+ dev,
+ nextExport,
reactServerErrorsByDigest,
allCapturedErrors,
silenceLogger,
@@ -2719,14 +2765,14 @@ async function prerenderToStream(
const selectStaleTime = (stale: number) =>
stale === INFINITE_CACHE &&
- typeof renderOpts.experimental.staleTimes?.static === 'number'
- ? renderOpts.experimental.staleTimes.static
+ typeof experimental.staleTimes?.static === 'number'
+ ? experimental.staleTimes.static
: stale
let prerenderStore: PrerenderStore | null = null
try {
- if (renderOpts.experimental.dynamicIO) {
+ if (experimental.dynamicIO) {
/**
* dynamicIO with PPR
*
@@ -2833,6 +2879,29 @@ async function prerenderToStream(
// We don't need to continue the prerender process if we already
// detected invalid dynamic usage in the initial prerender phase.
if (workStore.invalidDynamicUsageError) {
+ if (
+ isUseCacheTimeoutError(workStore.invalidDynamicUsageError) &&
+ allowEmptyStaticShell
+ ) {
+ // If this is a "use cache" timeout error, and empty shells are
+ // allowed (i.e. we're prerendering a fallback shell, and there are
+ // also more specific routes prerendered) we return an empty shell.
+ return {
+ digestErrorsMap: reactServerErrorsByDigest,
+ ssrErrors: allCapturedErrors,
+ stream: new ReadableStream({
+ start(controller) {
+ controller.close()
+ },
+ }),
+ collectedRevalidate: INFINITE_CACHE,
+ collectedExpire: INFINITE_CACHE,
+ collectedStale: selectStaleTime(INFINITE_CACHE),
+ collectedTags: null,
+ }
+ }
+
+ // Otherwise we throw the error to fail the build.
throw workStore.invalidDynamicUsageError
}
@@ -2887,7 +2956,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
{
@@ -2940,7 +3009,7 @@ async function prerenderToStream(
let serverIsDynamic = false
const finalServerController = new AbortController()
const serverDynamicTracking = createDynamicTrackingState(
- renderOpts.isDebugDynamicAccesses
+ isDebugDynamicAccesses
)
const finalRenderPrerenderStore: PrerenderStore = (prerenderStore = {
@@ -3010,7 +3079,7 @@ async function prerenderToStream(
))
const clientDynamicTracking = createDynamicTrackingState(
- renderOpts.isDebugDynamicAccesses
+ isDebugDynamicAccesses
)
const finalClientController = new AbortController()
const finalClientPrerenderStore: PrerenderStore = {
@@ -3048,7 +3117,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
{
@@ -3080,7 +3149,7 @@ async function prerenderToStream(
appendHeader(key, value)
})
},
- maxHeadersLength: renderOpts.reactMaxHeadersLength,
+ maxHeadersLength: reactMaxHeadersLength,
bootstrapScripts: [bootstrapScript],
}
),
@@ -3095,7 +3164,7 @@ async function prerenderToStream(
// If we've disabled throwing on empty static shell, then we don't need to
// track any dynamic access that occurs above the suspense boundary because
// we'll do so in the route shell.
- if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
+ if (!allowEmptyStaticShell) {
throwIfDisallowedDynamic(
workStore,
preludeIsEmpty,
@@ -3109,7 +3178,7 @@ async function prerenderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
})
@@ -3181,7 +3250,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
JSON.parse(JSON.stringify(postponed)),
@@ -3219,11 +3288,9 @@ async function prerenderToStream(
collectedTags: finalRenderPrerenderStore.tags,
}
}
- } else if (renderOpts.experimental.isRoutePPREnabled) {
+ } else if (experimental.isRoutePPREnabled) {
// We're statically generating with PPR and need to do dynamic tracking
- let dynamicTracking = createDynamicTrackingState(
- renderOpts.isDebugDynamicAccesses
- )
+ let dynamicTracking = createDynamicTrackingState(isDebugDynamicAccesses)
const prerenderResumeDataCache = createPrerenderResumeDataCache()
const reactServerPrerenderStore: PrerenderStore = (prerenderStore = {
@@ -3282,7 +3349,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
{
@@ -3292,7 +3359,7 @@ async function prerenderToStream(
appendHeader(key, value)
})
},
- maxHeadersLength: renderOpts.reactMaxHeadersLength,
+ maxHeadersLength: reactMaxHeadersLength,
bootstrapScripts: [bootstrapScript],
}
)
@@ -3300,7 +3367,7 @@ async function prerenderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
})
@@ -3415,7 +3482,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
JSON.parse(JSON.stringify(postponed)),
@@ -3495,7 +3562,7 @@ async function prerenderToStream(
clientReferenceManifest={clientReferenceManifest}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
ServerInsertedMetadataProvider={ServerInsertedMetadataProvider}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>,
{
@@ -3521,7 +3588,7 @@ async function prerenderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: allCapturedErrors,
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
})
return {
@@ -3592,10 +3659,7 @@ async function prerenderToStream(
errorType = 'redirect'
res.statusCode = getRedirectStatusCodeFromError(err)
- const redirectUrl = addPathPrefix(
- getURLFromRedirectError(err),
- renderOpts.basePath
- )
+ const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath)
setHeader('location', redirectUrl)
} else if (!shouldBailoutToCSR) {
@@ -3603,10 +3667,10 @@ async function prerenderToStream(
}
const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts(
- renderOpts.buildManifest,
+ buildManifest,
assetPrefix,
- renderOpts.crossOrigin,
- renderOpts.subresourceIntegrityManifest,
+ crossOrigin,
+ subresourceIntegrityManifest,
getAssetQueryString(ctx, false),
nonce,
'/_not-found/page'
@@ -3660,7 +3724,7 @@ async function prerenderToStream(
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
preinitScripts={errorPreinitScripts}
clientReferenceManifest={clientReferenceManifest}
- gracefullyDegrade={!!ctx.renderOpts.botType}
+ gracefullyDegrade={!!botType}
nonce={nonce}
/>
),
@@ -3686,8 +3750,6 @@ async function prerenderToStream(
)
}
- const validateRootLayout = renderOpts.dev
-
// This is intentionally using the readable datastream from the main
// render rather than the flight data from the error page render
const flightStream =
@@ -3711,11 +3773,11 @@ async function prerenderToStream(
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: [],
- basePath: renderOpts.basePath,
+ basePath,
tracingMetadata: tracingMetadata,
}),
getServerInsertedMetadata,
- validateRootLayout,
+ validateRootLayout: dev,
}),
dynamicAccess: null,
collectedRevalidate:
diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx
index 9c2f4b7a72ac..7d5e6e326a31 100644
--- a/packages/next/src/server/app-render/create-component-tree.tsx
+++ b/packages/next/src/server/app-render/create-component-tree.tsx
@@ -21,7 +21,10 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-context.shar
import type { Params } from '../request/params'
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
import { OUTLET_BOUNDARY_NAME } from '../../lib/metadata/metadata-constants'
-import type { UseCachePageComponentProps } from '../use-cache/use-cache-wrapper'
+import type {
+ UseCacheLayoutComponentProps,
+ UseCachePageComponentProps,
+} from '../use-cache/use-cache-wrapper'
/**
* Use the provided loader tree to create the React Component tree.
@@ -677,16 +680,21 @@ async function createComponentTreeInternal({
workStore
)
- // TODO(useCache): Should we use this trick also if dynamicIO is enabled,
- // instead of relying on the searchParams being a hanging promise?
- if (!experimental.dynamicIO && isUseCacheFunction(PageComponent)) {
+ // If we are passing searchParams to a server component Page we need to
+ // track their usage in case the current render mode tracks dynamic API
+ // usage.
+ let searchParams = createServerSearchParamsForServerPage(query, workStore)
+
+ if (isUseCacheFunction(PageComponent)) {
const UseCachePageComponent: React.ComponentType =
PageComponent
- // The "use cache" wrapper takes care of converting this into an
- // erroring search params promise when passing it to the original
- // function.
- const searchParams = Promise.resolve({})
+ if (!experimental.dynamicIO) {
+ // The "use cache" wrapper takes care of converting this into an
+ // erroring search params promise when passing it to the original
+ // function.
+ searchParams = Promise.resolve({})
+ }
pageElement = (
)
} else {
- // If we are passing searchParams to a server component Page we need to
- // track their usage in case the current render mode tracks dynamic API
- // usage.
- const searchParams = createServerSearchParamsForServerPage(
- query,
- workStore
- )
-
pageElement = (
)
@@ -845,9 +845,24 @@ async function createComponentTreeInternal({
workStore
)
- let serverSegment = (
-
- )
+ let serverSegment: React.ReactNode
+
+ if (isUseCacheFunction(SegmentComponent)) {
+ const UseCacheLayoutComponent: React.ComponentType =
+ SegmentComponent
+
+ serverSegment = (
+
+ )
+ } else {
+ serverSegment = (
+
+ )
+ }
if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) {
// TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`.
diff --git a/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts b/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts
new file mode 100644
index 000000000000..4364e4447ae2
--- /dev/null
+++ b/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts
@@ -0,0 +1,5 @@
+import { createAsyncLocalStorage } from './async-local-storage'
+import type { DynamicAccessStorage } from './dynamic-access-async-storage.external'
+
+export const dynamicAccessAsyncStorageInstance: DynamicAccessStorage =
+ createAsyncLocalStorage()
diff --git a/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts b/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts
new file mode 100644
index 000000000000..b1e39da00050
--- /dev/null
+++ b/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts
@@ -0,0 +1,11 @@
+import type { AsyncLocalStorage } from 'async_hooks'
+
+// Share the instance module in the next-shared layer
+import { dynamicAccessAsyncStorageInstance } from './dynamic-access-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
+
+export interface DynamicAccessAsyncStore {
+ readonly abortController: AbortController
+}
+
+export type DynamicAccessStorage = AsyncLocalStorage
+export { dynamicAccessAsyncStorageInstance as dynamicAccessAsyncStorage }
diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts
index f997afe93ba1..d9684dddfc74 100644
--- a/packages/next/src/server/app-render/types.ts
+++ b/packages/next/src/server/app-render/types.ts
@@ -269,11 +269,12 @@ export interface RenderOptsPartial {
isStaticGeneration?: boolean
/**
- * When true, the page will be rendered using the static rendering to detect
- * any dynamic API's that would have stopped the page from being fully
- * statically generated.
+ * When true, the page is prerendered as a fallback shell, while allowing any
+ * dynamic accesses to result in an empty shell. This is the case when there
+ * are also routes prerendered with a more complete set of params.
+ * Prerendering those routes would catch any invalid dynamic accesses.
*/
- doNotThrowOnEmptyStaticShell?: boolean
+ allowEmptyStaticShell?: boolean
/**
* next config experimental.devtoolSegmentExplorer
diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts
index db8fd037a5c5..239bb196ce88 100644
--- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts
+++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts
@@ -163,6 +163,11 @@ export interface CommonCacheStore
* from which implicit tags could be inherited.
*/
readonly implicitTags: ImplicitTags | undefined
+ /**
+ * Draft mode is only available if the outer work unit store is a request
+ * store and draft mode is enabled.
+ */
+ readonly draftMode: DraftModeProvider | undefined
}
export interface UseCacheStore extends CommonCacheStore {
@@ -179,21 +184,20 @@ export interface UseCacheStore extends CommonCacheStore {
readonly isHmrRefresh: boolean
readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined
readonly forceRevalidate: boolean
- // Draft mode is only available if the outer work unit store is a request
- // store and draft mode is enabled.
- readonly draftMode: DraftModeProvider | undefined
}
export interface UnstableCacheStore extends CommonCacheStore {
type: 'unstable-cache'
- // Draft mode is only available if the outer work unit store is a request
- // store and draft mode is enabled.
- readonly draftMode: DraftModeProvider | undefined
}
/**
- * The Cache store is for tracking information inside a "use cache" or unstable_cache context.
- * Inside this context we should never expose any request or page specific information.
+ * The Cache store is for tracking information inside a "use cache" or
+ * unstable_cache context. A cache store shadows an outer request store (if
+ * present) as a work unit, so that we never accidentally expose any request or
+ * page specific information to cache functions, unless it's explicitly desired.
+ * For those exceptions, the data is copied over from the request store to the
+ * cache store, instead of generally making the request store available to cache
+ * functions.
*/
export type CacheStore = UseCacheStore | UnstableCacheStore
diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts
index 9bb2cb10b52b..e0800f749b85 100644
--- a/packages/next/src/server/config-schema.ts
+++ b/packages/next/src/server/config-schema.ts
@@ -41,7 +41,7 @@ const zExportMap: zod.ZodType = z.record(
_isAppDir: z.boolean().optional(),
_isDynamicError: z.boolean().optional(),
_isRoutePPREnabled: z.boolean().optional(),
- _doNotThrowOnEmptyStaticShell: z.boolean().optional(),
+ _allowEmptyStaticShell: z.boolean().optional(),
})
)
diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts
index a0e61bb6eda1..529ea5fce298 100644
--- a/packages/next/src/server/config-shared.ts
+++ b/packages/next/src/server/config-shared.ts
@@ -750,13 +750,15 @@ export type ExportPathMap = {
_isRoutePPREnabled?: boolean
/**
- * When true, it indicates that the diagnostic render for this page is
- * disabled. This is only used when the app has `experimental.ppr` and
- * `experimental.dynamicIO` enabled.
+ * When true, the page is prerendered as a fallback shell, while allowing
+ * any dynamic accesses to result in an empty shell. This is the case when
+ * the app has `experimental.ppr` and `experimental.dynamicIO` enabled, and
+ * there are also routes prerendered with a more complete set of params.
+ * Prerendering those routes would catch any invalid dynamic accesses.
*
* @internal
*/
- _doNotThrowOnEmptyStaticShell?: boolean
+ _allowEmptyStaticShell?: boolean
}
}
diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts
index f0ee12bd034a..1425a7381fef 100644
--- a/packages/next/src/server/request/params.ts
+++ b/packages/next/src/server/request/params.ts
@@ -24,6 +24,7 @@ import {
import { makeHangingPromise } from '../dynamic-rendering-utils'
import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger'
import { scheduleImmediate } from '../../lib/scheduler'
+import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external'
export type ParamValue = string | Array | undefined
export type Params = Record
@@ -198,6 +199,33 @@ function createRenderParams(
interface CacheLifetime {}
const CachedParams = new WeakMap>()
+const fallbackParamsProxyHandler: ProxyHandler> = {
+ get: function get(target, prop, receiver) {
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
+ const originalMethod = ReflectAdapter.get(target, prop, receiver)
+
+ return {
+ [prop]: (...args: unknown[]) => {
+ const store = dynamicAccessAsyncStorage.getStore()
+
+ if (store) {
+ store.abortController.abort(
+ new Error(`Accessed fallback \`params\` during prerendering.`)
+ )
+ }
+
+ return new Proxy(
+ originalMethod.apply(target, args),
+ fallbackParamsProxyHandler
+ )
+ },
+ }[prop]
+ }
+
+ return ReflectAdapter.get(target, prop, receiver)
+ },
+}
+
function makeAbortingExoticParams(
underlyingParams: Params,
route: string,
@@ -208,10 +236,11 @@ function makeAbortingExoticParams(
return cachedParams
}
- const promise = makeHangingPromise(
- prerenderStore.renderSignal,
- '`params`'
+ const promise = new Proxy(
+ makeHangingPromise(prerenderStore.renderSignal, '`params`'),
+ fallbackParamsProxyHandler
)
+
CachedParams.set(underlyingParams, promise)
Object.keys(underlyingParams).forEach((prop) => {
diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts
index ad2fde25292f..7b1c0ec839c9 100644
--- a/packages/next/src/server/use-cache/use-cache-wrapper.ts
+++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts
@@ -52,6 +52,7 @@ import {
import type { Params } from '../request/params'
import React from 'react'
import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result'
+import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external'
type CacheKeyParts =
| [buildId: string, id: string, args: unknown[]]
@@ -63,6 +64,15 @@ export interface UseCachePageComponentProps {
$$isPageComponent: true
}
+export type UseCacheLayoutComponentProps = {
+ params: Promise
+ $$isLayoutComponent: true
+} & {
+ // The value type should be React.ReactNode. But such an index signature would
+ // be incompatible with the other two props.
+ [slot: string]: any
+}
+
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE
@@ -76,7 +86,7 @@ function generateCacheEntry(
encodedArguments: FormData | string,
fn: (...args: unknown[]) => Promise,
timeoutError: UseCacheTimeoutError
-): Promise<[ReadableStream, Promise]> {
+) {
// We need to run this inside a clean AsyncLocalStorage snapshot so that the cache
// generation cannot read anything from the context we're currently executing which
// might include request specific things like cookies() inside a React.cache().
@@ -173,16 +183,18 @@ function generateCacheEntryWithCacheContext(
getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore),
}
- return workUnitAsyncStorage.run(
- cacheStore,
- generateCacheEntryImpl,
- workStore,
- outerWorkUnitStore,
- cacheStore,
- clientReferenceManifest,
- encodedArguments,
- fn,
- timeoutError
+ return workUnitAsyncStorage.run(cacheStore, () =>
+ dynamicAccessAsyncStorage.run(
+ { abortController: new AbortController() },
+ generateCacheEntryImpl,
+ workStore,
+ outerWorkUnitStore,
+ cacheStore,
+ clientReferenceManifest,
+ encodedArguments,
+ fn,
+ timeoutError
+ )
)
}
@@ -307,6 +319,17 @@ async function collectResult(
return entry
}
+type GenerateCacheEntryResult =
+ | {
+ readonly type: 'cached'
+ readonly stream: ReadableStream
+ readonly pendingCacheEntry: Promise
+ }
+ | {
+ readonly type: 'prerender-dynamic'
+ readonly hangingPromise: Promise
+ }
+
async function generateCacheEntryImpl(
workStore: WorkStore,
outerWorkUnitStore: WorkUnitStore | undefined,
@@ -315,7 +338,7 @@ async function generateCacheEntryImpl(
encodedArguments: FormData | string,
fn: (...args: unknown[]) => Promise,
timeoutError: UseCacheTimeoutError
-): Promise<[ReadableStream, Promise]> {
+): Promise {
const temporaryReferences = createServerTemporaryReferenceSet()
const [, , args] =
@@ -400,12 +423,16 @@ async function generateCacheEntryImpl(
timeoutAbortController.abort(timeoutError)
}, 50000)
- const { renderSignal } = outerWorkUnitStore
+ const dynamicAccessAbortSignal =
+ dynamicAccessAsyncStorage.getStore()?.abortController.signal
- const abortSignal = AbortSignal.any([
- renderSignal,
- timeoutAbortController.signal,
- ])
+ const abortSignal = dynamicAccessAbortSignal
+ ? AbortSignal.any([
+ dynamicAccessAbortSignal,
+ outerWorkUnitStore.renderSignal,
+ timeoutAbortController.signal,
+ ])
+ : timeoutAbortController.signal
const { prelude } = await prerender(
resultPromise,
@@ -427,11 +454,31 @@ async function generateCacheEntryImpl(
clearTimeout(timer)
if (timeoutAbortController.signal.aborted) {
+ // When the timeout is reached we always error the stream. Even for
+ // fallback shell prerenders we don't want to return a hanging promise,
+ // which would allow the function to become a dynamic hole. Because that
+ // would mean that a non-empty shell could be generated which would be
+ // subject to revalidation, and we don't want to create long revalidation
+ // times.
stream = new ReadableStream({
start(controller) {
controller.error(timeoutError)
},
})
+ } else if (dynamicAccessAbortSignal?.aborted) {
+ // If the prerender is aborted because of dynamic access (e.g. reading
+ // fallback params), we return a hanging promise. This essentially makes
+ // the "use cache" function dynamic.
+ const hangingPromise = makeHangingPromise(
+ outerWorkUnitStore.renderSignal,
+ abortSignal.reason
+ )
+
+ if (outerWorkUnitStore?.type === 'prerender') {
+ outerWorkUnitStore.cacheSignal?.endRead()
+ }
+
+ return { type: 'prerender-dynamic', hangingPromise }
} else {
stream = prelude
}
@@ -449,7 +496,7 @@ async function generateCacheEntryImpl(
const [returnStream, savedStream] = stream.tee()
- const promiseOfCacheEntry = collectResult(
+ const pendingCacheEntry = collectResult(
savedStream,
workStore,
outerWorkUnitStore,
@@ -458,10 +505,14 @@ async function generateCacheEntryImpl(
errors
)
- // Return the stream as we're creating it. This means that if it ends up
- // erroring we cannot return a stale-while-error version but it allows
- // streaming back the result earlier.
- return [returnStream, promiseOfCacheEntry]
+ return {
+ type: 'cached',
+ // Return the stream as we're creating it. This means that if it ends up
+ // erroring we cannot return a stale-if-error version but it allows
+ // streaming back the result earlier.
+ stream: returnStream,
+ pendingCacheEntry,
+ }
}
function cloneCacheEntry(entry: CacheEntry): [CacheEntry, CacheEntry] {
@@ -596,31 +647,61 @@ export function cache(
? createHangingInputAbortSignal(workUnitStore)
: undefined
- // When dynamicIO is not enabled, we can not encode searchParams as
- // hanging promises. To still avoid unused search params from making a
- // page dynamic, we overwrite them here with a promise that resolves to an
- // empty object, while also overwriting the to-be-invoked function for
- // generating a cache entry with a function that creates an erroring
- // searchParams prop before invoking the original function. This ensures
- // that used searchParams inside of cached functions would still yield an
- // error.
- if (!workStore.dynamicIOEnabled && isPageComponent(args)) {
- const [{ params, searchParams }] = args
+ let isPageOrLayout = false
+
+ // For page and layout components, the cache function is overwritten,
+ // which allows us to apply special handling for params and searchParams.
+ // For pages and layouts we're using the outer params prop, and not the
+ // inner one that was serialized/deserialized. While it's not generally
+ // true for "use cache" args, in the case of `params` the inner and outer
+ // object are essentially equivalent, so this is safe to do (including
+ // fallback params that are hanging promises). It allows us to avoid
+ // waiting for the timeout, when prerendering a fallback shell of a cached
+ // page or layout that awaits params.
+ if (isPageComponent(args)) {
+ isPageOrLayout = true
+
+ const [{ params: outerParams, searchParams: outerSearchParams }] = args
// Overwrite the props to omit $$isPageComponent.
- args = [{ params, searchParams }]
+ args = [{ params: outerParams, searchParams: outerSearchParams }]
fn = {
[name]: async ({
- params: serializedParams,
+ params: _innerParams,
+ searchParams: innerSearchParams,
}: Omit) =>
originalFn.apply(null, [
{
- params: serializedParams,
- searchParams:
- makeErroringExoticSearchParamsForUseCache(workStore),
+ params: outerParams,
+ searchParams: workStore.dynamicIOEnabled
+ ? innerSearchParams
+ : // When dynamicIO is not enabled, we can not encode
+ // searchParams as a hanging promise. To still avoid unused
+ // search params from making a page dynamic, we define them
+ // in `createComponentTree` as a promise that resolves to an
+ // empty object. And here, we're creating an erroring
+ // searchParams prop, when invoking the original function.
+ // This ensures that used searchParams inside of cached
+ // functions would still yield an error.
+ makeErroringExoticSearchParamsForUseCache(workStore),
},
]),
}[name] as (...args: unknown[]) => Promise
+ } else if (isLayoutComponent(args)) {
+ isPageOrLayout = true
+
+ const [{ params: outerParams, $$isLayoutComponent, ...outerSlots }] =
+ args
+ // Overwrite the props to omit $$isLayoutComponent.
+ args = [{ params: outerParams, ...outerSlots }]
+
+ fn = {
+ [name]: async ({
+ params: _innerParams,
+ ...innerSlots
+ }: Omit) =>
+ originalFn.apply(null, [{ params: outerParams, ...innerSlots }]),
+ }[name] as (...args: unknown[]) => Promise
}
if (boundArgsLength > 0) {
@@ -654,10 +735,38 @@ export function cache(
? [buildId, id, args, hmrRefreshHash]
: [buildId, id, args]
- const encodedCacheKeyParts: FormData | string = await encodeReply(
- cacheKeyParts,
- { temporaryReferences, signal: hangingInputAbortSignal }
- )
+ const encodeCacheKeyParts = () =>
+ encodeReply(cacheKeyParts, {
+ temporaryReferences,
+ signal: hangingInputAbortSignal,
+ })
+
+ let encodedCacheKeyParts: FormData | string
+
+ if (workUnitStore?.type === 'prerender' && !isPageOrLayout) {
+ // If the "use cache" function is not a page or a layout, we need to
+ // track dynamic access already when encoding the arguments. If params
+ // are passed explicitly into a "use cache" function (as opposed to
+ // receiving them automatically in a page or layout), we assume that the
+ // params are also accessed. This allows us to abort early, and treat
+ // the function as dynamic, instead of waiting for the timeout to be
+ // reached.
+ const dynamicAccessAbortController = new AbortController()
+
+ encodedCacheKeyParts = await dynamicAccessAsyncStorage.run(
+ { abortController: dynamicAccessAbortController },
+ encodeCacheKeyParts
+ )
+
+ if (dynamicAccessAbortController.signal.aborted) {
+ return makeHangingPromise(
+ workUnitStore.renderSignal,
+ dynamicAccessAbortController.signal.reason.message
+ )
+ }
+ } else {
+ encodedCacheKeyParts = await encodeCacheKeyParts()
+ }
const serializedCacheKey =
typeof encodedCacheKeyParts === 'string'
@@ -829,7 +938,7 @@ export function cache(
}
}
- const [newStream, pendingCacheEntry] = await generateCacheEntry(
+ const result = await generateCacheEntry(
workStore,
workUnitStore,
clientReferenceManifest,
@@ -838,6 +947,12 @@ export function cache(
timeoutError
)
+ if (result.type === 'prerender-dynamic') {
+ return result.hangingPromise
+ }
+
+ const { stream: newStream, pendingCacheEntry } = result
+
// When draft mode is enabled, we must not save the cache entry.
if (!workStore.isDraftMode) {
let savedCacheEntry
@@ -892,40 +1007,44 @@ export function cache(
}
if (currentTime > entry.timestamp + entry.revalidate * 1000) {
- // If this is stale, and we're not in a prerender (i.e. this is dynamic render),
- // then we should warm up the cache with a fresh revalidated entry.
- const [ignoredStream, pendingCacheEntry] = await generateCacheEntry(
+ // If this is stale, and we're not in a prerender (i.e. this is
+ // dynamic render), then we should warm up the cache with a fresh
+ // revalidated entry.
+ const result = await generateCacheEntry(
workStore,
- undefined, // This is not running within the context of this unit.
+ // This is not running within the context of this unit.
+ undefined,
clientReferenceManifest,
encodedCacheKeyParts,
fn,
timeoutError
)
- let savedCacheEntry: Promise
- if (prerenderResumeDataCache) {
- const split = clonePendingCacheEntry(pendingCacheEntry)
- savedCacheEntry = getNthCacheEntry(split, 0)
- prerenderResumeDataCache.cache.set(
+ if (result.type === 'cached') {
+ const { stream: ignoredStream, pendingCacheEntry } = result
+ let savedCacheEntry: Promise
+
+ if (prerenderResumeDataCache) {
+ const split = clonePendingCacheEntry(pendingCacheEntry)
+ savedCacheEntry = getNthCacheEntry(split, 0)
+ prerenderResumeDataCache.cache.set(
+ serializedCacheKey,
+ getNthCacheEntry(split, 1)
+ )
+ } else {
+ savedCacheEntry = pendingCacheEntry
+ }
+
+ const promise = cacheHandler.set(
serializedCacheKey,
- getNthCacheEntry(split, 1)
+ savedCacheEntry
)
- } else {
- savedCacheEntry = pendingCacheEntry
- }
- const promise = cacheHandler.set(
- serializedCacheKey,
- savedCacheEntry
- )
+ workStore.pendingRevalidateWrites ??= []
+ workStore.pendingRevalidateWrites.push(promise)
- if (!workStore.pendingRevalidateWrites) {
- workStore.pendingRevalidateWrites = []
+ await ignoredStream.cancel()
}
- workStore.pendingRevalidateWrites.push(promise)
-
- await ignoredStream.cancel()
}
}
}
@@ -979,6 +1098,23 @@ function isPageComponent(
)
}
+function isLayoutComponent(
+ args: any[]
+): args is [UseCacheLayoutComponentProps, undefined] {
+ if (args.length !== 2) {
+ return false
+ }
+
+ const [props, ref] = args
+
+ return (
+ ref === undefined && // server components receive an undefined ref arg
+ props !== null &&
+ typeof props === 'object' &&
+ (props as UseCacheLayoutComponentProps).$$isLayoutComponent
+ )
+}
+
function shouldForceRevalidate(
workStore: WorkStore,
workUnitStore: WorkUnitStore | undefined
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx
new file mode 100644
index 000000000000..35df71aa8770
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx
@@ -0,0 +1,15 @@
+export async function LastModified({ params }) {
+ const { slug } = await params
+
+ return (
+
+ Page /{slug} last modified: {new Date().toISOString()}
+
+ )
+}
+
+export async function CachedLastModified({ params }) {
+ 'use cache'
+
+ return
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts
new file mode 100644
index 000000000000..4571ba8f47bb
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts
@@ -0,0 +1,7 @@
+const { PHASE_PRODUCTION_BUILD } = require('next/constants')
+
+export function getSentinelValue() {
+ return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
+ ? 'buildtime'
+ : 'runtime'
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx
new file mode 100644
index 000000000000..7e85641b121d
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx
@@ -0,0 +1,17 @@
+'use cache'
+
+import { Suspense } from 'react'
+import { getSentinelValue } from '../sentinel'
+
+export default async function Layout({ children }) {
+ return (
+
+
+
+ Layout: {new Date().toISOString()}
+
+ Loading...
}>{children}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..d4d9730aceee
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx
@@ -0,0 +1,11 @@
+'use cache'
+
+import { LastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..8b9bedf3e6c7
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx
@@ -0,0 +1,9 @@
+import { CachedLastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..6c479b8e4fa3
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx
@@ -0,0 +1,16 @@
+import { CachedLastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return (
+ <>
+ ({ slug: p.slug }))} />
+ {})} />
+ {})} />
+
+ >
+ )
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx
new file mode 100644
index 000000000000..a9acdbda7e2b
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx
@@ -0,0 +1,16 @@
+'use cache'
+
+import { getSentinelValue } from '../sentinel'
+
+export default async function Layout({ children }) {
+ return (
+
+
+
+ Layout: {new Date().toISOString()}
+
+ {children}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..d4d9730aceee
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx
@@ -0,0 +1,11 @@
+'use cache'
+
+import { LastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..8b9bedf3e6c7
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx
@@ -0,0 +1,9 @@
+import { CachedLastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx
new file mode 100644
index 000000000000..6c479b8e4fa3
--- /dev/null
+++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx
@@ -0,0 +1,16 @@
+import { CachedLastModified } from '../../../last-modified'
+
+export default async function Page({ params }) {
+ return (
+ <>
+ ({ slug: p.slug }))} />
+ {})} />
+ {})} />
+
+ >
+ )
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx
similarity index 100%
rename from test/e2e/app-dir/empty-fallback-shells/app/[slug]/page.jsx
rename to test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/without-io/layout.jsx
similarity index 100%
rename from test/e2e/app-dir/empty-fallback-shells/app/layout.jsx
rename to test/e2e/app-dir/empty-fallback-shells/app/without-io/layout.jsx
diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts
index 04089778ea26..c72980fb040d 100644
--- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts
+++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts
@@ -1,21 +1,170 @@
import { nextTestSetup } from 'e2e-utils'
describe('empty-fallback-shells', () => {
- const { next, isNextDeploy } = nextTestSetup({
+ const { next, isNextDev, isNextDeploy, isNextStart } = nextTestSetup({
files: __dirname,
})
- it('should start and not postpone the response', async () => {
- const res = await next.fetch('/world')
- const html = await res.text()
- expect(html).toContain('hello-world')
+ describe('without IO', () => {
+ it('should start and not postpone the response', async () => {
+ const res = await next.fetch('/without-io/world')
+ const html = await res.text()
+ expect(html).toContain('hello-world')
- if (isNextDeploy) {
- expect(res.headers.get('x-matched-path')).toBe('/[slug]')
- }
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe('/without-io/[slug]')
+ }
- // If we didn't use the fallback shell, then we didn't postpone the response
- // and therefore shouldn't have sent the postponed header.
- expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
+ // If we didn't use the fallback shell, then we didn't postpone the
+ // response and therefore shouldn't have sent the postponed header.
+ expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
+ })
})
+
+ describe('with cached IO', () => {
+ describe('and the page wrapped in Suspense', () => {
+ describe('and the params accessed in the cached page', () => {
+ it('resumes a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/with-suspense/params-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toContain('page-bar')
+
+ if (isNextDev) {
+ expect(html).toContain('layout-runtime')
+ } else {
+ expect(html).toContain('layout-buildtime')
+ }
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/with-suspense/params-in-page/[slug]'
+ )
+ } else if (isNextStart) {
+ expect(res.headers.get('x-nextjs-postponed')).toBe('1')
+ }
+ })
+ })
+
+ describe('and the params accessed in cached non-page function', () => {
+ it('resumes a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/with-suspense/params-not-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toContain('page-bar')
+
+ if (isNextDev) {
+ expect(html).toContain('layout-runtime')
+ } else {
+ expect(html).toContain('layout-buildtime')
+ }
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/with-suspense/params-not-in-page/[slug]'
+ )
+ } else if (isNextStart) {
+ expect(res.headers.get('x-nextjs-postponed')).toBe('1')
+ }
+ })
+ })
+
+ describe('and params.then/catch/finally passed to a cached function', () => {
+ it('resumes a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/with-suspense/params-then-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toIncludeRepeated('data-testid="page-bar"', 4)
+
+ if (isNextDev) {
+ expect(html).toContain('layout-runtime')
+ } else {
+ expect(html).toContain('layout-buildtime')
+ }
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/with-suspense/params-then-in-page/[slug]'
+ )
+ } else if (isNextStart) {
+ expect(res.headers.get('x-nextjs-postponed')).toBe('1')
+ }
+ })
+ })
+ })
+
+ describe('and the page not wrapped in Suspense', () => {
+ describe('and the params accessed in the cached page', () => {
+ it('does not resume a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/without-suspense/params-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toContain('page-bar')
+ expect(html).toContain('layout-runtime')
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/without-suspense/params-in-page/[slug]'
+ )
+ } else {
+ expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
+ }
+ })
+ })
+
+ describe('and the params accessed in a cached non-page function', () => {
+ it('does not resume a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/without-suspense/params-not-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toContain('page-bar')
+ expect(html).toContain('layout-runtime')
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/without-suspense/params-not-in-page/[slug]'
+ )
+ } else {
+ expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
+ }
+ })
+ })
+
+ describe('and params.then/catch/finally passed to a cached function', () => {
+ it('does not resume a postponed fallback shell', async () => {
+ const res = await next.fetch(
+ '/with-cached-io/without-suspense/params-then-in-page/bar'
+ )
+
+ const html = await res.text()
+ expect(html).toIncludeRepeated('data-testid="page-bar"', 4)
+ expect(html).toContain('layout-runtime')
+
+ if (isNextDeploy) {
+ expect(res.headers.get('x-matched-path')).toBe(
+ '/with-cached-io/without-suspense/params-then-in-page/[slug]'
+ )
+ } else {
+ expect(res.headers.get('x-nextjs-postponed')).not.toBe('1')
+ }
+ })
+ })
+ })
+ })
+
+ if (isNextStart) {
+ it('should not log a HANGING_PROMISE_REJECTION error', async () => {
+ expect(next.cliOutput).not.toContain('HANGING_PROMISE_REJECTION')
+ })
+ }
})
diff --git a/test/e2e/app-dir/empty-fallback-shells/next.config.js b/test/e2e/app-dir/empty-fallback-shells/next.config.js
index ac4afcf43219..3dac20d4703c 100644
--- a/test/e2e/app-dir/empty-fallback-shells/next.config.js
+++ b/test/e2e/app-dir/empty-fallback-shells/next.config.js
@@ -4,6 +4,7 @@
const nextConfig = {
experimental: {
dynamicIO: true,
+ prerenderEarlyExit: false,
},
}
diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx
deleted file mode 100644
index 1f77b26ac270..000000000000
--- a/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-'use cache'
-
-export default async function Page({
- params,
-}: {
- params: Promise<{ slug: string }>
-}) {
- const { slug } = await params
-
- return slug: {slug}
-}
-
-// If generateStaticParams would be used here to define at least one set of
-// complete params, we would not yield a timeout error.
diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx
new file mode 100644
index 000000000000..0c2be6ac6c88
--- /dev/null
+++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx
@@ -0,0 +1,25 @@
+async function LastModified({ params }) {
+ 'use cache'
+
+ const { slug } = await params
+
+ return (
+
+ Page /{slug} last modified: {new Date().toISOString()}
+
+ )
+}
+
+async function transformParams(params) {
+ const { slug } = await params
+
+ return { slug }
+}
+
+export default async function Page({ params }) {
+ return
+}
+
+export async function generateStaticParams() {
+ return [{ slug: 'foo' }]
+}
diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts
index 196219534525..5ed2ff8e4faf 100644
--- a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts
+++ b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts
@@ -335,15 +335,15 @@ describe('use-cache-hanging-inputs', () => {
)
expect(cliOutput).toInclude(
- createExpectedBuildErrorMessage('/fallback-params/[slug]')
+ createExpectedBuildErrorMessage('/search-params')
)
expect(cliOutput).toInclude(
- createExpectedBuildErrorMessage('/search-params')
+ createExpectedBuildErrorMessage('/search-params-caught')
)
expect(cliOutput).toInclude(
- createExpectedBuildErrorMessage('/search-params-caught')
+ createExpectedBuildErrorMessage('/transformed-params/[slug]')
)
expect(cliOutput).toInclude(