From 1b9e6cfd2dbd6a15765414e5a8636850f6e64d88 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jun 2025 15:10:51 -0500 Subject: [PATCH] feat: allow Dynamic RSC requests to get the postponed state so it may use the embedded Resume Data Cache --- packages/next/errors.json | 4 +- packages/next/src/build/index.ts | 2 + packages/next/src/build/templates/app-page.ts | 122 +++++----- .../next/src/server/app-render/app-render.tsx | 10 +- .../src/server/async-storage/request-store.ts | 6 +- packages/next/src/server/base-server.ts | 67 ++---- .../incremental-cache/file-system-cache.ts | 37 ++- .../src/server/lib/incremental-cache/index.ts | 40 +++- packages/next/src/server/request-meta.ts | 27 ++- .../next/src/server/response-cache/types.ts | 5 + .../custom-routes/test/index.test.js | 1 + .../dynamic-routing/test/index.test.js | 1 + test/ppr-tests-manifest.json | 3 +- .../app-dir/resume-data-cache/app/layout.tsx | 9 + .../app-dir/resume-data-cache/app/page.tsx | 32 +++ .../resume-data-cache/app/revalidate/route.ts | 6 + .../app-dir/resume-data-cache/next.config.js | 11 + .../resume-data-cache.test.ts | 109 +++++++++ .../app/dyn/[slug]/page.js | 2 +- .../app/rewrite/[slug]/page.js | 12 +- .../required-server-files-ppr.test.ts | 212 ++++++++++++------ 21 files changed, 512 insertions(+), 206 deletions(-) create mode 100644 test/production/app-dir/resume-data-cache/app/layout.tsx create mode 100644 test/production/app-dir/resume-data-cache/app/page.tsx create mode 100644 test/production/app-dir/resume-data-cache/app/revalidate/route.ts create mode 100644 test/production/app-dir/resume-data-cache/next.config.js create mode 100644 test/production/app-dir/resume-data-cache/resume-data-cache.test.ts diff --git a/packages/next/errors.json b/packages/next/errors.json index c5896248569a..b2f99a20c313 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -712,5 +712,7 @@ "711": "Can't resolve %s", "712": "`rspack.warnForEdgeRuntime` is not supported by the wasm bindings.", "713": "Unexpected error during process lookup", - "714": "cannot run loadNative when `NEXT_TEST_WASM` is set" + "714": "cannot run loadNative when `NEXT_TEST_WASM` is set", + "715": "expected a result to be returned", + "716": "expected a page response, got %s" } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 82356d5f0a82..5fcfd7d6ae42 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -413,6 +413,7 @@ export type RoutesManifest = { prefetchSegmentHeader: typeof NEXT_ROUTER_SEGMENT_PREFETCH_HEADER prefetchSegmentDirSuffix: typeof RSC_SEGMENTS_DIR_SUFFIX prefetchSegmentSuffix: typeof RSC_SEGMENT_SUFFIX + dynamicRSCPrerender: boolean } rewriteHeaders: { pathHeader: typeof NEXT_REWRITTEN_PATH_HEADER @@ -1336,6 +1337,7 @@ export default async function build( prefetchSegmentHeader: NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, prefetchSegmentSuffix: RSC_SEGMENT_SUFFIX, prefetchSegmentDirSuffix: RSC_SEGMENTS_DIR_SUFFIX, + dynamicRSCPrerender: isAppPPREnabled, }, rewriteHeaders: { pathHeader: NEXT_REWRITTEN_PATH_HEADER, diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index aad1b98bfade..f15aa8ef18d0 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -36,6 +36,7 @@ import { import { getBotType, isBot } from '../../shared/lib/router/utils/is-bot' import { CachedRouteKind, + IncrementalCacheKind, type CachedAppPageValue, type CachedPageValue, type ResponseCacheEntry, @@ -126,7 +127,6 @@ export async function handler( const multiZoneDraftMode = process.env .__NEXT_MULTI_ZONE_DRAFT_MODE as any as boolean - const initialPostponed = getRequestMeta(req, 'postponed') // TODO: replace with more specific flags const minimalMode = getRequestMeta(req, 'minimalMode') @@ -260,7 +260,9 @@ export async function handler( // If we're in minimal mode, then try to get the postponed information from // the request metadata. If available, use it for resuming the postponed // render. - const minimalPostponed = isRoutePPREnabled ? initialPostponed : undefined + const minimalPostponed = isRoutePPREnabled + ? getRequestMeta(req, 'postponed') + : undefined // If PPR is enabled, and this is a RSC request (but not a prefetch), then // we can use this fact to only generate the flight data for the request @@ -297,10 +299,10 @@ export async function handler( !isSSG || // If this request has provided postponed data, it supports dynamic // HTML. - typeof initialPostponed === 'string' || + typeof minimalPostponed === 'string' || // If this is a dynamic RSC request, then this render supports dynamic // HTML (it's dynamic). - isDynamicRSCRequest + (isDynamicRSCRequest && !minimalMode) // When html bots request PPR page, perform the full dynamic rendering. const shouldWaitOnAllReady = isHtmlBot && isRoutePPREnabled @@ -415,6 +417,8 @@ export async function handler( }) } + const incrementalCache = getRequestMeta(req, 'incrementalCache') + const doRender = async ({ span, postponed, @@ -432,6 +436,9 @@ export async function handler( */ fallbackRouteParams: FallbackRouteParams | null }): Promise => { + // When we're resuming a render, we should allow dynamic response. + if (typeof postponed === 'string') supportsDynamicResponse = true + const context: AppPageRouteHandlerContext = { query, params, @@ -457,8 +464,7 @@ export async function handler( postponed, shouldWaitOnAllReady, serveStreamingMetadata, - supportsDynamicResponse: - typeof postponed === 'string' || supportsDynamicResponse, + supportsDynamicResponse, buildManifest, nextFontManifest, reactLoadableManifest, @@ -486,21 +492,11 @@ export async function handler( reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, multiZoneDraftMode, - incrementalCache: getRequestMeta(req, 'incrementalCache'), + incrementalCache, cacheLifeProfiles: nextConfig.experimental.cacheLife, basePath: nextConfig.basePath, serverActions: nextConfig.experimental.serverActions, - ...(isDebugStaticShell || isDebugDynamicAccesses - ? { - nextExport: true, - supportsDynamicResponse: false, - isStaticGeneration: true, - isRevalidate: true, - isDebugDynamicAccesses: isDebugDynamicAccesses, - } - : {}), - experimental: { isRoutePPREnabled, expireTime: nextConfig.expireTime, @@ -534,6 +530,14 @@ export async function handler( }, } + if (isDebugStaticShell || isDebugDynamicAccesses) { + context.renderOpts.nextExport = true + context.renderOpts.supportsDynamicResponse = false + context.renderOpts.isStaticGeneration = true + context.renderOpts.isRevalidate = true + context.renderOpts.isDebugDynamicAccesses = isDebugDynamicAccesses + } + const result = await invokeRouteModule(span, context) const { metadata } = result @@ -699,13 +703,42 @@ export async function handler( } } } + // Only requests that aren't revalidating can be resumed. If we have the // minimal postponed data, then we should resume the render with it. - const postponed = + let postponed = !isOnDemandRevalidate && !isRevalidating && minimalPostponed ? minimalPostponed : undefined + // If this is a dynamic RSC request, we should use the postponed data from + // the static render (if available). This ensures that we can utilize the + // resume data cache (RDC) from the static render to ensure that the data + // is consistent between the static and dynamic renders. + if ( + process.env.NEXT_RUNTIME !== 'edge' && + !minimalMode && + incrementalCache && + isDynamicRSCRequest + ) { + const cachedEntry = await incrementalCache.get(resolvedPathname, { + kind: IncrementalCacheKind.APP_PAGE, + isRoutePPREnabled: true, + isFallback: false, + allowStale: true, + }) + + // If the cache entry is found, we should use the postponed data from + // the cache. + if ( + cachedEntry && + cachedEntry.value && + cachedEntry.value.kind === CachedRouteKind.APP_PAGE + ) { + postponed = cachedEntry.value.postponed + } + } + // When we're in minimal mode, if we're trying to debug the static shell, // we should just return nothing instead of resuming the dynamic render. if ( @@ -831,12 +864,7 @@ export async function handler( // If this is in minimal mode and this is a flight request that isn't a // prefetch request while PPR is enabled, it cannot be cached as it contains // dynamic content. - else if ( - minimalMode && - isRSCRequest && - !isPrefetchRSCRequest && - isRoutePPREnabled - ) { + else if (isDynamicRSCRequest) { cacheControl = { revalidate: 0, expire: undefined } } else if (!routeModule.isDev) { // If this is a preview mode request, we shouldn't cache it @@ -933,34 +961,15 @@ export async function handler( // If there's a callback for `onCacheEntry`, call it with the cache entry // and the revalidate options. - const onCacheEntry = getRequestMeta(req, 'onCacheEntry') + const onCacheEntry = + getRequestMeta(req, 'onCacheEntryV2') ?? + // TODO: Remove this once we've migrated to `onCacheEntryV2` + getRequestMeta(req, 'onCacheEntry') if (onCacheEntry) { - const finished = await onCacheEntry( - { - ...cacheEntry, - // TODO: remove this when upstream doesn't - // always expect this value to be "PAGE" - value: { - ...cacheEntry.value, - kind: 'PAGE', - }, - }, - { - url: getRequestMeta(req, 'initURL'), - } - ) - if (finished) { - // TODO: maybe we have to end the request? - return null - } - } - - // If the request has a postponed state and it's a resume request we - // should error. - if (didPostpone && minimalPostponed) { - throw new Error( - 'Invariant: postponed state should not be present on a resume request' - ) + const finished = await onCacheEntry(cacheEntry, { + url: getRequestMeta(req, 'initURL') ?? req.url, + }) + if (finished) return null } if (cachedData.headers) { @@ -1033,14 +1042,7 @@ export async function handler( generateEtags: nextConfig.generateEtags, poweredByHeader: nextConfig.poweredByHeader, result: cachedData.html, - // Dynamic RSC responses cannot be cached, even if they're - // configured with `force-static` because we have no way of - // distinguishing between `force-static` and pages that have no - // postponed state. - // TODO: distinguish `force-static` from pages with no postponed state (static) - cacheControl: isDynamicRSCRequest - ? { revalidate: 0, expire: undefined } - : cacheEntry.cacheControl, + cacheControl: cacheEntry.cacheControl, }) } @@ -1058,7 +1060,7 @@ export async function handler( } // This is a request for HTML data. - let body = cachedData.html + const body = cachedData.html // If there's no postponed state, we should just serve the HTML. This // should also be the case for a resume request because it's completed diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 9b207251b420..a7bd5ea4d2a8 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1509,7 +1509,9 @@ async function renderToHTMLOrFlightImpl( } else { // We're rendering dynamically const renderResumeDataCache = - renderOpts.renderResumeDataCache ?? postponedState?.renderResumeDataCache + renderOpts.renderResumeDataCache ?? + postponedState?.renderResumeDataCache ?? + null const rootParams = getRootParams(loaderTree, ctx.getDynamicParamFromSegment) const requestStore = createRequestStoreForRender( @@ -1563,6 +1565,9 @@ async function renderToHTMLOrFlightImpl( let formState: null | any = null if (isPossibleActionRequest) { + // For action requests, we don't want to use the resume data cache. + requestStore.renderResumeDataCache = null + // For action requests, we handle them differently with a special render result. const actionRequestResult = await handleAction({ req, @@ -1603,6 +1608,9 @@ async function renderToHTMLOrFlightImpl( } } } + + // Restore the resume data cache + requestStore.renderResumeDataCache = renderResumeDataCache } const options: RenderResultOptions = { diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 89558398a700..2334db7fb731 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -114,7 +114,7 @@ export function createRequestStoreForRender( previewProps: WrapperRenderOpts['previewProps'], isHmrRefresh: RequestContext['isHmrRefresh'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'], - renderResumeDataCache: RenderResumeDataCache | undefined + renderResumeDataCache: RenderResumeDataCache | null ): RequestStore { return createRequestStoreImpl( // Pages start in render phase by default @@ -148,7 +148,7 @@ export function createRequestStoreForAPI( {}, implicitTags, onUpdateCookies, - undefined, + null, previewProps, false, undefined @@ -163,7 +163,7 @@ function createRequestStoreImpl( rootParams: Params, implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], - renderResumeDataCache: RenderResumeDataCache | undefined, + renderResumeDataCache: RenderResumeDataCache | null, previewProps: WrapperRenderOpts['previewProps'], isHmrRefresh: RequestContext['isHmrRefresh'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 821179b23c0d..b80d933e43ab 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -3034,9 +3034,7 @@ export default abstract class Server< fallbackResponse = await this.responseCache.get( isProduction ? (locale ? `/${locale}${pathname}` : pathname) : null, // This is the response generator for the fallback shell. - async ({ - previousCacheEntry: previousFallbackCacheEntry = null, - }) => { + ({ previousCacheEntry: previousFallbackCacheEntry = null }) => { // For the pages router, fallbacks cannot be revalidated or // generated in production. In the case of a missing fallback, // we return null, but if it's being revalidated, we just return @@ -3077,7 +3075,7 @@ export default abstract class Server< fallbackResponse = await this.responseCache.get( isProduction ? pathname : null, // This is the response generator for the fallback shell. - async () => + () => doRender({ // We pass `undefined` as rendering a fallback isn't resumed // here. @@ -3107,7 +3105,7 @@ export default abstract class Server< if (fallbackResponse) { // Remove the cache control from the response to prevent it from being // used in the surrounding cache. - delete fallbackResponse.cacheControl + fallbackResponse.cacheControl = undefined return fallbackResponse } @@ -3272,15 +3270,9 @@ export default abstract class Server< cacheControl = { revalidate: 0, expire: undefined } } - // If this is in minimal mode and this is a flight request that isn't a - // prefetch request while PPR is enabled, it cannot be cached as it contains - // dynamic content. - else if ( - this.minimalMode && - isRSCRequest && - !isPrefetchRSCRequest && - isRoutePPREnabled - ) { + // If this is a flight request that isn't a pre-fetch request while PPR is + // enabled, it cannot be cached as it contains dynamic content. + else if (isDynamicRSCRequest) { cacheControl = { revalidate: 0, expire: undefined } } else if (!this.renderOpts.dev || (hasServerProps && !isNextDataRequest)) { // If this is a preview mode request, we shouldn't cache it @@ -3390,29 +3382,15 @@ export default abstract class Server< // If there's a callback for `onCacheEntry`, call it with the cache entry // and the revalidate options. - const onCacheEntry = getRequestMeta(req, 'onCacheEntry') + const onCacheEntry = + getRequestMeta(req, 'onCacheEntryV2') ?? + // TODO: Remove this once we've migrated to `onCacheEntryV2` + getRequestMeta(req, 'onCacheEntry') if (onCacheEntry) { - const finished = await onCacheEntry( - { - ...cacheEntry, - // TODO: remove this when upstream doesn't - // always expect this value to be "PAGE" - value: { - ...cacheEntry.value, - kind: - cacheEntry.value?.kind === CachedRouteKind.APP_PAGE - ? 'PAGE' - : cacheEntry.value?.kind, - }, - }, - { - url: getRequestMeta(req, 'initURL'), - } - ) - if (finished) { - // TODO: maybe we have to end the request? - return null - } + const finished = await onCacheEntry(cacheEntry, { + url: getRequestMeta(req, 'initURL') ?? req.url, + }) + if (finished) return null } if (!cachedData) { @@ -3528,7 +3506,7 @@ export default abstract class Server< } // Mark that the request did postpone. - if (didPostpone) { + if (didPostpone && !isDynamicRSCRequest) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') } @@ -3546,14 +3524,7 @@ export default abstract class Server< return { type: 'rsc', body: cachedData.html, - // Dynamic RSC responses cannot be cached, even if they're - // configured with `force-static` because we have no way of - // distinguishing between `force-static` and pages that have no - // postponed state. - // TODO: distinguish `force-static` from pages with no postponed state (static) - cacheControl: isDynamicRSCRequest - ? { revalidate: 0, expire: undefined } - : cacheEntry.cacheControl, + cacheControl: cacheEntry.cacheControl, } } @@ -3621,12 +3592,12 @@ export default abstract class Server< }) .then(async (result) => { if (!result) { - throw new Error('Invariant: expected a result to be returned') + throw new InvariantError('expected a result to be returned') } if (result.value?.kind !== CachedRouteKind.APP_PAGE) { - throw new Error( - `Invariant: expected a page response, got ${result.value?.kind}` + throw new InvariantError( + `expected a page response, got ${result.value?.kind}` ) } diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 51bd58dabbf4..bb5c9b2182e3 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -276,6 +276,16 @@ export default class FileSystemCache implements CacheHandler { } } + // If enabled, this will return the possibly stale data without validating + // that the tags have expired or not yet been revalidated. + if ('allowStale' in ctx && ctx.allowStale) { + if (FileSystemCache.debug) { + console.log('allow stale', ctx.allowStale) + } + + return data ?? null + } + if ( data?.value?.kind === CachedRouteKind.APP_PAGE || data?.value?.kind === CachedRouteKind.PAGES @@ -292,6 +302,10 @@ export default class FileSystemCache implements CacheHandler { // had a tag revalidated, if we want to be a background // revalidation instead we return data.lastModified = -1 if (isStale(cacheTags, data?.lastModified || Date.now())) { + if (FileSystemCache.debug) { + console.log('stale tags', cacheTags) + } + return null } } @@ -301,17 +315,22 @@ export default class FileSystemCache implements CacheHandler { ? [...(ctx.tags || []), ...(ctx.softTags || [])] : [] - const wasRevalidated = combinedTags.some((tag) => { - if (this.revalidatedTags.includes(tag)) { - return true + // When revalidate tag is called we don't return stale data so it's + // updated right away. + if (combinedTags.some((tag) => this.revalidatedTags.includes(tag))) { + if (FileSystemCache.debug) { + console.log('was revalidated', combinedTags) + } + + return null + } + + if (isStale(combinedTags, data?.lastModified || Date.now())) { + if (FileSystemCache.debug) { + console.log('stale tags', combinedTags) } - return isStale([tag], data?.lastModified || Date.now()) - }) - // When revalidate tag is called we don't return - // stale data so it's updated right away - if (wasRevalidated) { - data = undefined + return null } } diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 2794b4c0bf19..088bcc7806fc 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -15,7 +15,6 @@ import { type SetIncrementalResponseCacheContext, } from '../../response-cache' import type { DeepReadonly } from '../../../shared/lib/deep-readonly' - import FileSystemCache from './file-system-cache' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' @@ -89,8 +88,8 @@ export class IncrementalCache implements IncrementalCacheType { readonly allowedRevalidateHeaderKeys?: string[] readonly minimalMode?: boolean readonly fetchCacheKeyPrefix?: string - readonly revalidatedTags?: string[] readonly isOnDemandRevalidate?: boolean + readonly revalidatedTags?: readonly string[] private static readonly debug: boolean = !!process.env.NEXT_PRIVATE_DEBUG_CACHE @@ -179,7 +178,7 @@ export class IncrementalCache implements IncrementalCacheType { } if (minimalMode) { - revalidatedTags = getPreviouslyRevalidatedTags( + revalidatedTags = this.revalidatedTags = getPreviouslyRevalidatedTags( requestHeaders, this.prerenderManifest?.preview?.previewModeId ) @@ -426,7 +425,13 @@ export class IncrementalCache implements IncrementalCacheType { if (resumeDataCache) { const memoryCacheData = resumeDataCache.fetch.get(cacheKey) if (memoryCacheData?.kind === CachedRouteKind.FETCH) { + if (IncrementalCache.debug) { + console.log('rdc:hit', cacheKey) + } + return { isStale: false, value: memoryCacheData } + } else if (IncrementalCache.debug) { + console.log('rdc:miss', cacheKey) } } } @@ -470,9 +475,34 @@ export class IncrementalCache implements IncrementalCacheType { workStore?.pendingRevalidatedTags?.includes(tag) ) ) { + if (IncrementalCache.debug) { + console.log('stale tag', cacheKey) + } + return null } + // As we're able to get the cache entry for this fetch, and the prerender + // resume data cache (RDC) is available, it must have been populated by a + // previous fetch, but was not yet present in the in-memory cache. This + // could be the case when performing multiple renders in parallel during + // build time where we de-duplicate the fetch calls. + // + // We add it to the RDC so that the next fetch call will be able to use it + // and it won't have to reach into the fetch cache implementation. + const workUnitStore = workUnitAsyncStorage.getStore() + if (workUnitStore) { + const prerenderResumeDataCache = + getPrerenderResumeDataCache(workUnitStore) + if (prerenderResumeDataCache) { + if (IncrementalCache.debug) { + console.log('rdc:set', cacheKey) + } + + prerenderResumeDataCache.fetch.set(cacheKey, cacheData.value) + } + } + const revalidate = ctx.revalidate || cacheData.value.revalidate const age = (performance.timeOrigin + @@ -571,6 +601,10 @@ export class IncrementalCache implements IncrementalCacheType { ? getPrerenderResumeDataCache(workUnitStore) : null if (prerenderResumeDataCache) { + if (IncrementalCache.debug) { + console.log('rdc:set', pathname) + } + prerenderResumeDataCache.fetch.set(pathname, data) } } diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts index 28a8b3be8a99..f39d20b47cf8 100644 --- a/packages/next/src/server/request-meta.ts +++ b/packages/next/src/server/request-meta.ts @@ -6,8 +6,12 @@ import type { BaseNextRequest } from './base-http' import type { CloneableBody } from './body-streams' import type { RouteMatch } from './route-matches/route-match' import type { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers' -import type { ServerComponentsHmrCache } from './response-cache' +import type { + ResponseCacheEntry, + ServerComponentsHmrCache, +} from './response-cache' import type { PagesDevOverlayBridgeType } from '../next-devtools/userspace/pages/pages-dev-overlay-setup' +import type { IncrementalCache } from './lib/incremental-cache' // FIXME: (wyattjoh) this is a temporary solution to allow us to pass data between bundled modules export const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta') @@ -68,7 +72,7 @@ export interface RequestMeta { /** * The incremental cache to use for the request. */ - incrementalCache?: any + incrementalCache?: IncrementalCache /** * The server components HMR cache, only for dev. @@ -118,10 +122,25 @@ export interface RequestMeta { /** * If provided, this will be called when a response cache entry was generated * or looked up in the cache. + * + * @deprecated Use `onCacheEntryV2` instead. */ onCacheEntry?: ( - cacheEntry: any, - requestMeta: any + cacheEntry: ResponseCacheEntry, + requestMeta: { + url: string | undefined + } + ) => Promise | boolean | void + + /** + * If provided, this will be called when a response cache entry was generated + * or looked up in the cache. + */ + onCacheEntryV2?: ( + cacheEntry: ResponseCacheEntry, + requestMeta: { + url: string | undefined + } ) => Promise | boolean | void /** diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 04b9daa8535e..eabefeb9a45e 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -219,6 +219,11 @@ export interface GetIncrementalResponseCacheContext { * True if this is a fallback request. */ isFallback: boolean + + /** + * True if stale data is allowed to be returned. + */ + allowStale?: boolean } export interface SetIncrementalFetchCacheContext { diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 137b025028f7..14e1de493719 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -2570,6 +2570,7 @@ const runTests = (isDev = false) => { prefetchSegmentSuffix: '.segment.rsc', prefetchSuffix: '.prefetch.rsc', suffix: '.rsc', + dynamicRSCPrerender: !!process.env.__NEXT_EXPERIMENTAL_PPR, }, }) }) diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index b6be113ce580..11b4929f456d 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -1529,6 +1529,7 @@ function runTests({ dev }) { prefetchSegmentSuffix: '.segment.rsc', prefetchSuffix: '.prefetch.rsc', suffix: '.rsc', + dynamicRSCPrerender: !!process.env.__NEXT_EXPERIMENTAL_PPR, }, }) }) diff --git a/test/ppr-tests-manifest.json b/test/ppr-tests-manifest.json index 32d34ee70e60..4bcad8af0589 100644 --- a/test/ppr-tests-manifest.json +++ b/test/ppr-tests-manifest.json @@ -174,7 +174,8 @@ "test/e2e/app-dir/use-cache-route-handler-only/**/*", "test/integration/app-dir-export/**/*", "test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts", - "test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts" + "test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts", + "test/production/app-dir/resume-data-cache/resume-data-cache.test.ts" ] } } diff --git a/test/production/app-dir/resume-data-cache/app/layout.tsx b/test/production/app-dir/resume-data-cache/app/layout.tsx new file mode 100644 index 000000000000..9dfde1627512 --- /dev/null +++ b/test/production/app-dir/resume-data-cache/app/layout.tsx @@ -0,0 +1,9 @@ +import React, { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/resume-data-cache/app/page.tsx b/test/production/app-dir/resume-data-cache/app/page.tsx new file mode 100644 index 000000000000..a42bd6f524a3 --- /dev/null +++ b/test/production/app-dir/resume-data-cache/app/page.tsx @@ -0,0 +1,32 @@ +import React, { Suspense } from 'react' +import { connection } from 'next/server' + +import { unstable_cacheTag } from 'next/cache' + +async function getRandomNumber() { + 'use cache' + unstable_cacheTag('test') + return Math.random() +} + +async function DynamicComponent() { + await connection() + return null +} + +export default async function Page() { + const randomNumber = await getRandomNumber() + const anotherRandomNumber = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { cache: 'force-cache', next: { tags: ['test'] } } + ).then((res) => res.text()) + return ( + <> +

{randomNumber}

+

{anotherRandomNumber}

+ + + + + ) +} diff --git a/test/production/app-dir/resume-data-cache/app/revalidate/route.ts b/test/production/app-dir/resume-data-cache/app/revalidate/route.ts new file mode 100644 index 000000000000..4100bb53cdd0 --- /dev/null +++ b/test/production/app-dir/resume-data-cache/app/revalidate/route.ts @@ -0,0 +1,6 @@ +import { revalidateTag } from 'next/cache' + +export function POST() { + revalidateTag('test') + return new Response(null, { status: 200 }) +} diff --git a/test/production/app-dir/resume-data-cache/next.config.js b/test/production/app-dir/resume-data-cache/next.config.js new file mode 100644 index 000000000000..bea0706290af --- /dev/null +++ b/test/production/app-dir/resume-data-cache/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + useCache: true, + }, +} + +module.exports = nextConfig diff --git a/test/production/app-dir/resume-data-cache/resume-data-cache.test.ts b/test/production/app-dir/resume-data-cache/resume-data-cache.test.ts new file mode 100644 index 000000000000..b7c3a0ec7f71 --- /dev/null +++ b/test/production/app-dir/resume-data-cache/resume-data-cache.test.ts @@ -0,0 +1,109 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('resume-data-cache', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + // TODO: re-enable once support has been added + skipDeployment: true, + }) + if (skipped) return + + it.each([ + { name: 'use cache', id: 'random-number' }, + { name: 'fetch cache', id: 'another-random-number' }, + ])( + 'should have consistent data between static and dynamic renders with $name', + async ({ id }) => { + // First render the page statically, getting the random number from the + // HTML. + let $ = await next.render$('/') + const first = $(`p#${id}`).text() + + // Then get the Prefetch RSC and validate that it also contains the same + // random number. + let rsc + + await retry(async () => { + rsc = await next + .fetch('/', { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + }, + }) + .then((res) => res.text()) + expect(rsc).toContain(first) + }) + + // Then get the dynamic RSC and validate that it also contains the same + // random number. + await retry(async () => { + rsc = await next + .fetch('/', { + headers: { + RSC: '1', + }, + }) + .then((res) => res.text()) + expect(rsc).toContain(first) + }) + + // Then revalidate the page + await next.fetch('/revalidate', { method: 'POST' }) + + // Then get the dynamic RSC again and validate that it still contains the + // same random number. + await retry(async () => { + rsc = await next + .fetch('/', { + headers: { + RSC: '1', + }, + }) + .then((res) => res.text()) + expect(rsc).toContain(first) + }) + + // This proves that the dynamic RSC was able to use the resume data cache + // (RDC) from the static render to ensure that the data is consistent + // between the static and dynamic renders. Let's now try to render the + // page statically and see that the random number changes. + + $ = await next.render$('/') + const random2 = $(`p#${id}`).text() + expect(random2).not.toBe(first) + + // Then get the Prefetch RSC and validate that it also contains the new + // random number. + await retry(async () => { + rsc = await next + .fetch('/', { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + }, + }) + .then((res) => res.text()) + expect(rsc).toContain(random2) + }) + + // Then get the dynamic RSC again and validate that it also contains the + // new random number. + await retry(async () => { + rsc = await next + .fetch('/', { + headers: { + RSC: '1', + }, + }) + .then((res) => res.text()) + expect(rsc).toContain(random2) + }) + + // This proves that the dynamic RSC was able to use the resume data cache + // (RDC) from the static render to ensure that the data is consistent + // between the static and dynamic renders. + } + ) +}) diff --git a/test/production/standalone-mode/required-server-files/app/dyn/[slug]/page.js b/test/production/standalone-mode/required-server-files/app/dyn/[slug]/page.js index dc35c2f7d7d9..051ec6f7a056 100644 --- a/test/production/standalone-mode/required-server-files/app/dyn/[slug]/page.js +++ b/test/production/standalone-mode/required-server-files/app/dyn/[slug]/page.js @@ -1,7 +1,7 @@ import { headers } from 'next/headers' export default async function Page({ params }) { - const data = headers() + const data = await headers() return ( <> diff --git a/test/production/standalone-mode/required-server-files/app/rewrite/[slug]/page.js b/test/production/standalone-mode/required-server-files/app/rewrite/[slug]/page.js index 8267d9a68933..729ce481ad76 100644 --- a/test/production/standalone-mode/required-server-files/app/rewrite/[slug]/page.js +++ b/test/production/standalone-mode/required-server-files/app/rewrite/[slug]/page.js @@ -1,18 +1,24 @@ import { Suspense } from 'react' -import { unstable_noStore } from 'next/cache' +import { connection } from 'next/server' export function generateStaticParams() { return [{ slug: 'first-cookie' }] } -function Postpone({ children }) { - unstable_noStore() +async function Postpone({ children }) { + await connection() return children } export default async function Page({ params }) { + const random = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { cache: 'force-cache' } + ).then((res) => res.text()) + return ( <> +

{random}

/rewrite/[slug]

diff --git a/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts index 07f6636fdd73..3834ec4818ca 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts @@ -1,6 +1,5 @@ -import glob from 'glob' -import fs from 'fs-extra' -import { join } from 'path' +import fs from 'node:fs/promises' +import { join } from 'node:path' import cheerio from 'cheerio' import { createNext, FileRef } from 'e2e-utils' import { NextInstance } from 'e2e-utils' @@ -11,27 +10,22 @@ import { initNextServerScript, killApp, } from 'next-test-utils' -import { ChildProcess } from 'child_process' +import { ChildProcess } from 'node:child_process' describe('required server files app router', () => { let next: NextInstance let server: ChildProcess let appPort: number | string - let delayedPostpone - let rewritePostpone + let delayedPostpone: string + let rewritePostpone: string + let rewriteHTML: string let cliOutput = '' - const setupNext = async ({ - nextEnv, - minimalMode, - }: { - nextEnv?: boolean - minimalMode?: boolean - }) => { - // test build against environment with next support - process.env.NOW_BUILDER = nextEnv ? '1' : '' + beforeAll(async () => { + process.env.NOW_BUILDER = '1' process.env.NEXT_PRIVATE_TEST_HEADERS = '1' + // Setup the Next.js app and build it. next = await createNext({ files: { app: new FileRef(join(__dirname, 'app')), @@ -47,6 +41,7 @@ describe('required server files app router', () => { cacheHandler: './cache-handler.js', experimental: { ppr: true, + clientSegmentCache: true, }, eslint: { ignoreDuringBuilds: true, @@ -54,50 +49,49 @@ describe('required server files app router', () => { output: 'standalone', }, }) + + // Stop the server, we're going to restart it using the standalone server + // below after some cleanup. await next.stop() + // Read the postponed state and the HTML that was generated at build time + // from the output of the build. delayedPostpone = (await next.readJSON('.next/server/app/delayed.meta')) .postponed rewritePostpone = ( await next.readJSON('.next/server/app/rewrite/first-cookie.meta') ).postponed + rewriteHTML = await next.readFile( + '.next/server/app/rewrite/first-cookie.html' + ) - await fs.move( + await fs.rename( join(next.testDir, '.next/standalone'), join(next.testDir, 'standalone') ) - for (const file of await fs.readdir(next.testDir)) { - if (file !== 'standalone') { - await fs.remove(join(next.testDir, file)) - console.log('removed', file) - } - } - const files = glob.sync('**/*', { - cwd: join(next.testDir, 'standalone/.next/server/pages'), - dot: true, - }) - for (const file of files) { - if (file.endsWith('.json') || file.endsWith('.html')) { - await fs.remove(join(next.testDir, '.next/server', file)) - } - } + const serverFilePath = join(next.testDir, 'standalone/server.js') - const testServer = join(next.testDir, 'standalone/server.js') + // We're going to use the minimal mode for the server. await fs.writeFile( - testServer, - (await fs.readFile(testServer, 'utf8')).replace( + serverFilePath, + (await fs.readFile(serverFilePath, 'utf8')).replace( 'port:', - `minimalMode: ${minimalMode},port:` + `minimalMode: true, port:` ) ) + + // Find a port to use for the server. appPort = await findPort() + + // Then we can start the server with the new environment variables. server = await initNextServerScript( - testServer, + serverFilePath, /- Local:/, { ...process.env, PORT: `${appPort}`, + NEXT_PRIVATE_DEBUG_CACHE: '1', }, undefined, { @@ -110,11 +104,8 @@ describe('required server files app router', () => { }, } ) - } - - beforeAll(async () => { - await setupNext({ nextEnv: true, minimalMode: true }) }) + afterAll(async () => { delete process.env.NEXT_PRIVATE_TEST_HEADERS await next.destroy() @@ -125,37 +116,35 @@ describe('required server files app router', () => { expect(next.cliOutput).not.toContain('ERR_INVALID_URL') }) - // this enables client segment cache in CI - if (process.env.__NEXT_EXPERIMENTAL_PPR) { - it('should de-dupe client segment tree revalidate requests', async () => { - const { segmentPaths } = await next.readJSON( - 'standalone/.next/server/app/isr/first.meta' - ) - const outputIdx = cliOutput.length + it('should de-dupe client segment tree revalidate requests', async () => { + const { segmentPaths } = await next.readJSON( + 'standalone/.next/server/app/isr/first.meta' + ) + const outputIdx = cliOutput.length - for (const segmentPath of segmentPaths) { - const outputSegmentPath = - join('/isr/[slug].segments', segmentPath) + '.segment.rsc' + for (const segmentPath of segmentPaths) { + const outputSegmentPath = + join('/isr/[slug].segments', segmentPath) + '.segment.rsc' - require('console').error('requesting', outputSegmentPath) + require('console').error('requesting', outputSegmentPath) - const res = await fetchViaHTTP(appPort, outputSegmentPath, undefined, { - headers: { - 'x-matched-path': '/isr/[slug].segments/_tree.segment.rsc', - 'x-now-route-matches': 'slug=first&1=first', - }, - }) + const res = await fetchViaHTTP(appPort, outputSegmentPath, undefined, { + headers: { + 'x-matched-path': '/isr/[slug].segments/_tree.segment.rsc', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('text/x-component') - } + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/x-component') + } - expect( - cliOutput.substring(outputIdx).match(/rendering \/isr\/\[slug\]/g) - .length - ).toBe(1) - }) - } + expect( + cliOutput.substring(outputIdx).match(/rendering \/isr\/\[slug\]/g).length + ).toBe(1) + }) it('should properly stream resume with Next-Resume', async () => { const res = await fetchViaHTTP(appPort, '/delayed', undefined, { @@ -181,11 +170,6 @@ describe('required server files app router', () => { const firstSuspense = chunks.find((item) => item.chunk.includes('time')) const secondSuspense = chunks.find((item) => item.chunk.includes('random')) - console.log({ - firstSuspense, - secondSuspense, - }) - expect(secondSuspense.time - firstSuspense.time).toBeGreaterThanOrEqual( 2 * 1000 ) @@ -363,12 +347,15 @@ describe('required server files app router', () => { const res = await fetchViaHTTP(appPort, '/dyn/first.rsc', undefined, { headers: { 'x-matched-path': '/dyn/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), }, }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toEqual('text/x-component') - expect(res.headers.has('x-nextjs-postponed')).toBeFalse() + expect(res.headers.has('x-nextjs-postponed')).toBeTrue() }) it('should handle prefetch RSC requests', async () => { @@ -379,6 +366,9 @@ describe('required server files app router', () => { { headers: { 'x-matched-path': '/dyn/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), }, } ) @@ -388,6 +378,84 @@ describe('required server files app router', () => { expect(res.headers.has('x-nextjs-postponed')).toBeTrue() }) + it('should use the postponed state for the RSC requests', async () => { + // Let's parse the random number out of the HTML that was generated at build + // time. We want to use that value as it's the one that's tied to the + // postponed state that we also have. + const $ = cheerio.load(rewriteHTML) + + const random = $('#random').text() + expect(random).toBeDefined() + expect(random.length).toBeGreaterThan(0) + + // Record the start of the logs for this test. + let start = cliOutput.length + + // Then let's do a Dynamic RSC request and verify that the random value is + // not present in the response without passing the postponed state. + let res = await fetchViaHTTP( + appPort, + '/rewrite/first-cookie.rsc', + undefined, + { + headers: { + 'x-matched-path': '/rewrite/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first-cookie', + }).toString(), + }, + } + ) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toEqual('text/x-component') + expect(res.headers.has('x-nextjs-postponed')).toBeTrue() + + // Ensure that we hit the cache handler and not the resume data cache. + expect(cliOutput.substring(start)).toContain('cache-handler get') + expect(cliOutput.substring(start)).toContain('cache-handler set') + expect(cliOutput.substring(start)).toContain('rdc:miss') + expect(cliOutput.substring(start)).not.toContain('rdc:hit') + + // We expect that the random value is not present in the response because + // we're not providing a resume data cache via the postponed state. + // Instead it'll contain another random number that's been generated at + // runtime. + let rsc = await res.text() + expect(rsc).not.toContain(random) + + // Reset the start of the logs for this test. + start = cliOutput.length + + // Then let's get the Dynamic RSC request and verify that the random value + // is present in the response by passing the postponed state. + res = await fetchViaHTTP(appPort, '/rewrite/first-cookie.rsc', undefined, { + method: 'POST', + headers: { + 'x-matched-path': '/rewrite/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first-cookie', + }).toString(), + 'next-resume': '1', + }, + body: rewritePostpone, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toEqual('text/x-component') + expect(res.headers.has('x-nextjs-postponed')).toBeFalse() + + // Ensure that we hit the resume data cache and not the cache handler. + expect(cliOutput.substring(start)).not.toContain('cache-handler get') + expect(cliOutput.substring(start)).not.toContain('cache-handler set') + expect(cliOutput.substring(start)).toContain('rdc:hit') + + // We expect that the random value is present in the response because + // we're providing a resume data cache via the postponed state. + rsc = await res.text() + expect(rsc).toContain(random) + }) + it('should handle revalidating the fallback page', async () => { const res = await fetchViaHTTP(appPort, '/postpone/isr/[slug]', undefined, { headers: {