From 4070640da542bbf2b1a829e3c08363cf33053f1b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 4 Nov 2024 12:39:02 -0700 Subject: [PATCH] refactor: move resume data cache into postponed state --- packages/next/src/export/routes/app-page.ts | 12 --- packages/next/src/lib/constants.ts | 1 - .../next/src/server/app-render/app-render.tsx | 56 ++++++++----- .../server/app-render/postponed-state.test.ts | 71 ++++++++++++---- .../src/server/app-render/postponed-state.ts | 83 +++++++++++++++---- packages/next/src/server/app-render/types.ts | 2 +- .../src/server/async-storage/request-store.ts | 5 +- packages/next/src/server/base-server.ts | 30 ++----- .../incremental-cache/file-system-cache.ts | 43 ---------- packages/next/src/server/render-result.ts | 5 +- .../next/src/server/response-cache/types.ts | 4 - .../next/src/server/response-cache/utils.ts | 3 - .../server/resume-data-cache/cache-store.ts | 9 ++ .../resume-data-cache/serialization.test.ts | 42 ++++++++++ .../server/resume-data-cache/serialization.ts | 21 +++-- .../file-system-cache.test.ts | 1 - 16 files changed, 231 insertions(+), 157 deletions(-) create mode 100644 packages/next/src/server/resume-data-cache/serialization.test.ts diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index 3c399543951850..d803ef40c85be7 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -16,7 +16,6 @@ import { RSC_SUFFIX, RSC_SEGMENTS_DIR_SUFFIX, RSC_SEGMENT_SUFFIX, - NEXT_STATIC_DATA_CACHE_SUFFIX, } from '../../lib/constants' import { hasNextSupport } from '../../server/ci-info' import { lazyRenderAppPage } from '../../server/route-modules/app-page/module.render' @@ -28,7 +27,6 @@ import type { WorkStore } from '../../server/app-render/work-async-storage.exter import type { FallbackRouteParams } from '../../server/request/fallback-params' import { AfterRunner } from '../../server/after/run-with-after' import type { RequestLifecycleOpts } from '../../server/base-server' -import { stringifyResumeDataCache } from '../../server/resume-data-cache/serialization' export const enum ExportedAppPageFiles { HTML = 'HTML', @@ -37,7 +35,6 @@ export const enum ExportedAppPageFiles { PREFETCH_FLIGHT_SEGMENT = 'PREFETCH_FLIGHT_SEGMENT', META = 'META', POSTPONED = 'POSTPONED', - RESUME_CACHE = 'RESUME_CACHE', } export async function prospectiveRenderAppPage( @@ -150,7 +147,6 @@ export async function exportAppPage( fetchTags, fetchMetrics, segmentFlightData, - immutableResumeDataCache, } = metadata // Ensure we don't postpone without having PPR enabled. @@ -257,14 +253,6 @@ export async function exportAppPage( 'utf8' ) - if (immutableResumeDataCache) { - await fileWriter( - ExportedAppPageFiles.RESUME_CACHE, - htmlFilepath.replace(/\.html$/, NEXT_STATIC_DATA_CACHE_SUFFIX), - await stringifyResumeDataCache(immutableResumeDataCache) - ) - } - const isParallelRoute = /\/@\w+/.test(page) const isNonSuccessfulStatusCode = res.statusCode > 300 diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index 90d64ce8069a6e..fa5efaee99b57d 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -16,7 +16,6 @@ export const ACTION_SUFFIX = '.action' export const NEXT_DATA_SUFFIX = '.json' export const NEXT_META_SUFFIX = '.meta' export const NEXT_BODY_SUFFIX = '.body' -export const NEXT_STATIC_DATA_CACHE_SUFFIX = '.rdc.json' export const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags' export const NEXT_CACHE_SOFT_TAGS_HEADER = 'x-next-cache-soft-tags' diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index f11bbb142476a1..694026f423755b 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -655,7 +655,9 @@ async function warmupDevRender( // lift the warmup pathway outside of renderToHTML... but for now this suffices return new FlightRenderResult('', { fetchMetrics: ctx.workStore.fetchMetrics, - immutableResumeDataCache: sealResumeDataCache(mutableResumeDataCache), + devWarmupImmutableResumeDataCache: sealResumeDataCache( + mutableResumeDataCache + ), }) } @@ -1418,9 +1420,9 @@ export const renderToHTMLOrFlight: AppPageRender = ( // If provided, the postpone state should be parsed so it can be provided to // React. if (typeof renderOpts.postponed === 'string') { - if (fallbackRouteParams && fallbackRouteParams.size > 0) { - throw new Error( - 'Invariant: postponed state should not be provided when fallback params are provided' + if (fallbackRouteParams) { + throw new InvariantError( + 'postponed state should not be provided when fallback params are provided' ) } @@ -1430,6 +1432,19 @@ export const renderToHTMLOrFlight: AppPageRender = ( ) } + if ( + postponedState?.immutableResumeDataCache && + renderOpts.devWarmupImmutableResumeDataCache + ) { + throw new InvariantError( + 'postponed state and dev warmup immutable resume data cache should not be provided together' + ) + } + + const immutableResumeDataCache = + renderOpts.devWarmupImmutableResumeDataCache ?? + postponedState?.immutableResumeDataCache + const implicitTags = getImplicitTags( renderOpts.routeModule.definition.page, url, @@ -1446,7 +1461,7 @@ export const renderToHTMLOrFlight: AppPageRender = ( url, implicitTags, renderOpts.onUpdateCookies, - renderOpts.immutableResumeDataCache, + immutableResumeDataCache, renderOpts.previewProps, isHmrRefresh, serverComponentsHmrCache @@ -2741,9 +2756,6 @@ async function prerenderToStream( const flightData = await streamToBuffer(reactServerResult.asStream()) metadata.flightData = flightData - metadata.immutableResumeDataCache = sealResumeDataCache( - mutableResumeDataCache - ) metadata.segmentFlightData = await collectSegmentData( finalAttemptRSCPayload, flightData, @@ -2754,13 +2766,16 @@ async function prerenderToStream( if (serverIsDynamic || clientIsDynamic) { if (postponed != null) { // Dynamic HTML case - metadata.postponed = getDynamicHTMLPostponedState( + metadata.postponed = await getDynamicHTMLPostponedState( postponed, - fallbackRouteParams + fallbackRouteParams, + sealResumeDataCache(mutableResumeDataCache) ) } else { // Dynamic Data case - metadata.postponed = getDynamicDataPostponedState() + metadata.postponed = await getDynamicDataPostponedState( + sealResumeDataCache(mutableResumeDataCache) + ) } reactServerResult.consume() return { @@ -3203,9 +3218,6 @@ async function prerenderToStream( } } - metadata.immutableResumeDataCache = sealResumeDataCache( - mutableResumeDataCache - ) const flightData = await streamToBuffer( serverPrerenderStreamResult.asStream() ) @@ -3349,9 +3361,6 @@ async function prerenderToStream( renderOpts ) } - metadata.immutableResumeDataCache = sealResumeDataCache( - mutableResumeDataCache - ) /** * When prerendering there are three outcomes to consider @@ -3371,13 +3380,16 @@ async function prerenderToStream( if (accessedDynamicData(dynamicTracking.dynamicAccesses)) { if (postponed != null) { // Dynamic HTML case. - metadata.postponed = getDynamicHTMLPostponedState( + metadata.postponed = await getDynamicHTMLPostponedState( postponed, - fallbackRouteParams + fallbackRouteParams, + sealResumeDataCache(mutableResumeDataCache) ) } else { // Dynamic Data case. - metadata.postponed = getDynamicDataPostponedState() + metadata.postponed = await getDynamicDataPostponedState( + sealResumeDataCache(mutableResumeDataCache) + ) } // Regardless of whether this is the Dynamic HTML or Dynamic Data case we need to ensure we include // server inserted html in the static response because the html that is part of the prerender may depend on it @@ -3399,7 +3411,9 @@ async function prerenderToStream( } } else if (fallbackRouteParams && fallbackRouteParams.size > 0) { // Rendering the fallback case. - metadata.postponed = getDynamicDataPostponedState() + metadata.postponed = await getDynamicDataPostponedState( + sealResumeDataCache(mutableResumeDataCache) + ) return { digestErrorsMap: reactServerErrorsByDigest, diff --git a/packages/next/src/server/app-render/postponed-state.test.ts b/packages/next/src/server/app-render/postponed-state.test.ts index 555f018865bc5a..070ed8c2ba644c 100644 --- a/packages/next/src/server/app-render/postponed-state.test.ts +++ b/packages/next/src/server/app-render/postponed-state.test.ts @@ -1,3 +1,8 @@ +import { + createMutableResumeDataCache, + sealResumeDataCache, +} from '../resume-data-cache/resume-data-cache' +import { streamFromString } from '../stream-utils/node-web-streams-helper' import { DynamicState, getDynamicDataPostponedState, @@ -6,30 +11,50 @@ import { } from './postponed-state' describe('getDynamicHTMLPostponedState', () => { - it('serializes a HTML postponed state with fallback params', () => { + it('serializes a HTML postponed state with fallback params', async () => { const key = '%%drp:slug:e9615126684e5%%' const fallbackRouteParams = new Map([['slug', key]]) - const state = getDynamicHTMLPostponedState( + const mutableResumeDataCache = createMutableResumeDataCache() + + mutableResumeDataCache.cache.set( + '1', + Promise.resolve({ + value: streamFromString('hello'), + tags: [], + stale: 0, + timestamp: 0, + expire: 0, + revalidate: 0, + }) + ) + + const state = await getDynamicHTMLPostponedState( { [key]: key, nested: { [key]: key } }, - fallbackRouteParams + fallbackRouteParams, + sealResumeDataCache(mutableResumeDataCache) ) expect(state).toMatchInlineSnapshot( - `"39[["slug","%%drp:slug:e9615126684e5%%"]]{"%%drp:slug:e9615126684e5%%":"%%drp:slug:e9615126684e5%%","nested":{"%%drp:slug:e9615126684e5%%":"%%drp:slug:e9615126684e5%%"}}"` + `"169:39[["slug","%%drp:slug:e9615126684e5%%"]]{"%%drp:slug:e9615126684e5%%":"%%drp:slug:e9615126684e5%%","nested":{"%%drp:slug:e9615126684e5%%":"%%drp:slug:e9615126684e5%%"}}{"store":{"fetch":{},"cache":{"1":{"value":"aGVsbG8=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}}}}"` ) }) - it('serializes a HTML postponed state without fallback params', () => { - const state = getDynamicHTMLPostponedState({ key: 'value' }, null) - expect(state).toMatchInlineSnapshot(`"{"key":"value"}"`) + it('serializes a HTML postponed state without fallback params', async () => { + const state = await getDynamicHTMLPostponedState( + { key: 'value' }, + null, + sealResumeDataCache(createMutableResumeDataCache()) + ) + expect(state).toMatchInlineSnapshot(`"15:{"key":"value"}null"`) }) - it('can serialize and deserialize a HTML postponed state with fallback params', () => { + it('can serialize and deserialize a HTML postponed state with fallback params', async () => { const key = '%%drp:slug:e9615126684e5%%' const fallbackRouteParams = new Map([['slug', key]]) - const state = getDynamicHTMLPostponedState( + const state = await getDynamicHTMLPostponedState( { [key]: key }, - fallbackRouteParams + fallbackRouteParams, + sealResumeDataCache(createMutableResumeDataCache()) ) const value = 'hello' @@ -38,6 +63,9 @@ describe('getDynamicHTMLPostponedState', () => { expect(parsed).toEqual({ type: DynamicState.HTML, data: { [value]: value }, + immutableResumeDataCache: sealResumeDataCache( + createMutableResumeDataCache() + ), }) // The replacements have been replaced. @@ -46,15 +74,17 @@ describe('getDynamicHTMLPostponedState', () => { }) describe('getDynamicDataPostponedState', () => { - it('serializes a data postponed state with fallback params', () => { - const state = getDynamicDataPostponedState() - expect(state).toMatchInlineSnapshot(`"null"`) + it('serializes a data postponed state with fallback params', async () => { + const state = await getDynamicDataPostponedState( + sealResumeDataCache(createMutableResumeDataCache()) + ) + expect(state).toMatchInlineSnapshot(`"4:nullnull"`) }) }) describe('parsePostponedState', () => { it('parses a HTML postponed state with fallback params', () => { - const state = `39[["slug","%%drp:slug:e9615126684e5%%"]]{"t":2,"d":{"nextSegmentId":2,"rootFormatContext":{"insertionMode":0,"selectedValue":null,"tagScope":0},"progressiveChunkSize":12800,"resumableState":{"idPrefix":"","nextFormID":0,"streamingFormat":0,"instructions":0,"hasBody":true,"hasHtml":true,"unknownResources":{},"dnsResources":{},"connectResources":{"default":{},"anonymous":{},"credentials":{}},"imageResources":{},"styleResources":{},"scriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null,"/_next/static/chunks/f5e865f6-5e04edf75402c5e9.js":null,"/_next/static/chunks/9440-26a4cfbb73347735.js":null,"/_next/static/chunks/main-app-315ef55d588dbeeb.js":null,"/_next/static/chunks/8630-8e01a4bea783c651.js":null,"/_next/static/chunks/app/layout-1b900e1a3caf3737.js":null},"moduleUnknownResources":{},"moduleScriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null}},"replayNodes":[["oR",0,[["Context.Provider",0,[["ServerInsertedHTMLProvider",0,[["Context.Provider",0,[["n7",0,[["nU",0,[["nF",0,[["n9",0,[["Fragment",0,[["Context.Provider",2,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["nY",0,[["nX",0,[["Fragment","c",[["Fragment",0,[["html",1,[["body",0,[["main",3,[["j",0,[["Fragment",0,[["Context.Provider","validation",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["c",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","slug|%%drp:slug:e9615126684e5%%|d",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","__PAGE__",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Suspense",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["Fragment",0,[],{"1":1}]],null]],null]],null]],null]],null]],null]],null]],null,["Suspense Fallback",0,[],null],0]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],"replaySlots":null}}` + const state = `2589:39[["slug","%%drp:slug:e9615126684e5%%"]]{"t":2,"d":{"nextSegmentId":2,"rootFormatContext":{"insertionMode":0,"selectedValue":null,"tagScope":0},"progressiveChunkSize":12800,"resumableState":{"idPrefix":"","nextFormID":0,"streamingFormat":0,"instructions":0,"hasBody":true,"hasHtml":true,"unknownResources":{},"dnsResources":{},"connectResources":{"default":{},"anonymous":{},"credentials":{}},"imageResources":{},"styleResources":{},"scriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null,"/_next/static/chunks/f5e865f6-5e04edf75402c5e9.js":null,"/_next/static/chunks/9440-26a4cfbb73347735.js":null,"/_next/static/chunks/main-app-315ef55d588dbeeb.js":null,"/_next/static/chunks/8630-8e01a4bea783c651.js":null,"/_next/static/chunks/app/layout-1b900e1a3caf3737.js":null},"moduleUnknownResources":{},"moduleScriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null}},"replayNodes":[["oR",0,[["Context.Provider",0,[["ServerInsertedHTMLProvider",0,[["Context.Provider",0,[["n7",0,[["nU",0,[["nF",0,[["n9",0,[["Fragment",0,[["Context.Provider",2,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["nY",0,[["nX",0,[["Fragment","c",[["Fragment",0,[["html",1,[["body",0,[["main",3,[["j",0,[["Fragment",0,[["Context.Provider","validation",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["c",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","slug|%%drp:slug:e9615126684e5%%|d",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","__PAGE__",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Suspense",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["Fragment",0,[],{"1":1}]],null]],null]],null]],null]],null]],null]],null]],null,["Suspense Fallback",0,[],null],0]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],"replaySlots":null}}null` const params = { slug: Math.random().toString(16).slice(3), } @@ -64,6 +94,9 @@ describe('parsePostponedState', () => { expect(parsed).toEqual({ type: DynamicState.HTML, data: expect.any(Object), + immutableResumeDataCache: sealResumeDataCache( + createMutableResumeDataCache() + ), }) // Ensure that the replacement worked and removed all the placeholders. @@ -71,7 +104,7 @@ describe('parsePostponedState', () => { }) it('parses a HTML postponed state without fallback params', () => { - const state = `{}` + const state = `2:{}null` const params = {} const parsed = parsePostponedState(state, params) @@ -79,16 +112,22 @@ describe('parsePostponedState', () => { expect(parsed).toEqual({ type: DynamicState.HTML, data: expect.any(Object), + immutableResumeDataCache: sealResumeDataCache( + createMutableResumeDataCache() + ), }) }) it('parses a data postponed state', () => { - const state = 'null' + const state = '4:nullnull' const parsed = parsePostponedState(state, undefined) // Ensure that it parsed it correctly. expect(parsed).toEqual({ type: DynamicState.DATA, + immutableResumeDataCache: sealResumeDataCache( + createMutableResumeDataCache() + ), }) }) }) diff --git a/packages/next/src/server/app-render/postponed-state.ts b/packages/next/src/server/app-render/postponed-state.ts index 35db808dd62dad..980d8ac0e89ebe 100644 --- a/packages/next/src/server/app-render/postponed-state.ts +++ b/packages/next/src/server/app-render/postponed-state.ts @@ -1,5 +1,10 @@ import type { FallbackRouteParams } from '../../server/request/fallback-params' import type { Params } from '../request/params' +import type { ImmutableResumeDataCache } from '../resume-data-cache/resume-data-cache' +import { + parseResumeDataCache, + stringifyResumeDataCache, +} from '../resume-data-cache/serialization' export enum DynamicState { /** @@ -21,6 +26,11 @@ export type DynamicDataPostponedState = { * The type of dynamic state. */ readonly type: DynamicState.DATA + + /** + * The immutable resume data cache. + */ + readonly immutableResumeDataCache: ImmutableResumeDataCache } /** @@ -36,57 +46,94 @@ export type DynamicHTMLPostponedState = { * The postponed data used by React. */ readonly data: object + + /** + * The immutable resume data cache. + */ + readonly immutableResumeDataCache: ImmutableResumeDataCache } export type PostponedState = | DynamicDataPostponedState | DynamicHTMLPostponedState -export function getDynamicHTMLPostponedState( +export async function getDynamicHTMLPostponedState( data: object, - fallbackRouteParams: FallbackRouteParams | null -): string { + fallbackRouteParams: FallbackRouteParams | null, + immutableResumeDataCache: ImmutableResumeDataCache +): Promise { if (!fallbackRouteParams || fallbackRouteParams.size === 0) { - return JSON.stringify(data) + const postponedString = JSON.stringify(data) + + // Serialized as `:` + return `${postponedString.length}:${postponedString}${await stringifyResumeDataCache( + immutableResumeDataCache + )}` } const replacements: Array<[string, string]> = Array.from(fallbackRouteParams) const replacementsString = JSON.stringify(replacements) + const dataString = JSON.stringify(data) - // Serialized as `` - return `${replacementsString.length}${replacementsString}${JSON.stringify(data)}` + // Serialized as `` + const postponedString = `${replacementsString.length}${replacementsString}${dataString}` + + // Serialized as `:` + return `${postponedString.length}:${postponedString}${await stringifyResumeDataCache(immutableResumeDataCache)}` } -export function getDynamicDataPostponedState(): string { - return 'null' +export async function getDynamicDataPostponedState( + immutableResumeDataCache: ImmutableResumeDataCache +): Promise { + return `4:null${await stringifyResumeDataCache(immutableResumeDataCache)}` } export function parsePostponedState( state: string, params: Params | undefined ): PostponedState { + const postponedStringLengthMatch = state.match(/^([0-9]*):/)?.[1] + if (!postponedStringLengthMatch) { + throw new Error(`Invariant: invalid postponed state ${state}`) + } + + const postponedStringLength = parseInt(postponedStringLengthMatch) + + // We add a `:` to the end of the length as the first character of the + // postponed string is the length of the replacement entries. + const postponedString = state.slice( + postponedStringLengthMatch.length + 1, + postponedStringLengthMatch.length + postponedStringLength + 1 + ) + + const immutableResumeDataCache = parseResumeDataCache( + state.slice(postponedStringLengthMatch.length + postponedStringLength + 1) + ) + try { - if (state === 'null') { - return { type: DynamicState.DATA } + if (postponedString === 'null') { + return { type: DynamicState.DATA, immutableResumeDataCache } } - if (/^[0-9]/.test(state)) { - const match = state.match(/^([0-9]*)/)?.[1] + if (/^[0-9]/.test(postponedString)) { + const match = postponedString.match(/^([0-9]*)/)?.[1] if (!match) { - throw new Error(`Invariant: invalid postponed state ${state}`) + throw new Error( + `Invariant: invalid postponed state ${JSON.stringify(postponedString)}` + ) } // This is the length of the replacements entries. const length = parseInt(match) const replacements = JSON.parse( - state.slice( + postponedString.slice( match.length, // We then go to the end of the string. match.length + length ) ) as ReadonlyArray<[string, string]> - let postponed = state.slice(match.length + length) + let postponed = postponedString.slice(match.length + length) for (const [key, searchValue] of replacements) { const value = params?.[key] ?? '' const replaceValue = Array.isArray(value) ? value.join('/') : value @@ -96,16 +143,18 @@ export function parsePostponedState( return { type: DynamicState.HTML, data: JSON.parse(postponed), + immutableResumeDataCache, } } return { type: DynamicState.HTML, - data: JSON.parse(state), + data: JSON.parse(postponedString), + immutableResumeDataCache, } } catch (err) { console.error('Failed to parse postponed state', err) - return { type: DynamicState.DATA } + return { type: DynamicState.DATA, immutableResumeDataCache } } } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index ebd4dba6ee4f02..136bce4d8ccca2 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -189,7 +189,7 @@ export interface RenderOptsPartial { * The resume data cache that was generated for this partially prerendered * page or during rendering. */ - immutableResumeDataCache?: ImmutableResumeDataCache + devWarmupImmutableResumeDataCache?: ImmutableResumeDataCache /** * When true, only the static shell of the page will be rendered. This will diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 172a8892c49650..92282a029266cc 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -21,6 +21,7 @@ import { ResponseCookies, RequestCookies } from '../web/spec-extension/cookies' import { DraftModeProvider } from './draft-mode-provider' import { splitCookiesString } from '../web/utils' import type { ServerComponentsHmrCache } from '../response-cache' +import type { ImmutableResumeDataCache } from '../resume-data-cache/resume-data-cache' function getHeaders(headers: Headers | IncomingHttpHeaders): ReadonlyHeaders { const cleaned = HeadersAdapter.from(headers) @@ -107,7 +108,7 @@ export function createRequestStoreForRender( url: RequestContext['url'], implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], - immutableResumeDataCache: RenderOpts['immutableResumeDataCache'], + immutableResumeDataCache: ImmutableResumeDataCache | undefined, previewProps: WrapperRenderOpts['previewProps'], isHmrRefresh: RequestContext['isHmrRefresh'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] @@ -156,7 +157,7 @@ function createRequestStoreImpl( url: RequestContext['url'], implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], - immutableResumeDataCache: RenderOpts['immutableResumeDataCache'], + immutableResumeDataCache: ImmutableResumeDataCache | undefined, 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 a88bd9acda63cd..77ae1a3121fe5b 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -60,7 +60,6 @@ import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info' import type { TLSSocket } from 'tls' import type { PathnameNormalizer } from './normalizers/request/pathname-normalizer' import type { InstrumentationModule } from './instrumentation/types' -import type { ImmutableResumeDataCache } from './resume-data-cache/resume-data-cache' import { format as formatUrl, parse as parseUrl } from 'url' import { formatHostname } from './lib/format-hostname' @@ -2395,12 +2394,6 @@ export default abstract class Server< */ postponed: string | undefined - /** - * The resume data cache for this render. This is only provided when - * resuming a render that has been postponed. - */ - immutableResumeDataCache: ImmutableResumeDataCache | undefined - /** * The unknown route params for this render. */ @@ -2410,11 +2403,7 @@ export default abstract class Server< context: RendererContext ) => Promise - const doRender: Renderer = async ({ - postponed, - immutableResumeDataCache, - fallbackRouteParams, - }) => { + const doRender: Renderer = async ({ postponed, fallbackRouteParams }) => { // In development, we always want to generate dynamic HTML. let supportsDynamicResponse: boolean = // If we're in development, we always support dynamic HTML, unless it's @@ -2488,7 +2477,6 @@ export default abstract class Server< isDraftMode: isPreviewMode, isServerAction, postponed, - immutableResumeDataCache, waitUntil: this.getWaitUntil(), onClose: res.onClose.bind(res), onAfterTaskError: undefined, @@ -2590,7 +2578,6 @@ export default abstract class Server< status: response.status, body: Buffer.from(await blob.arrayBuffer()), headers, - immutableResumeDataCache: undefined, }, revalidate, isFallback: false, @@ -2692,6 +2679,7 @@ export default abstract class Server< serverComponentsHmrCache: this.getServerComponentsHmrCache(), } + // TODO: adapt for putting the RDC inside the postponed data // If we're in dev, and this isn't s prefetch or a server action, // we should seed the resume data cache. if ( @@ -2704,9 +2692,9 @@ export default abstract class Server< // If the warmup is successful, we should use the resume data // cache from the warmup. - if (warmup.metadata.immutableResumeDataCache) { - renderOpts.immutableResumeDataCache = - warmup.metadata.immutableResumeDataCache + if (warmup.metadata.devWarmupImmutableResumeDataCache) { + renderOpts.devWarmupImmutableResumeDataCache = + warmup.metadata.devWarmupImmutableResumeDataCache } } @@ -2806,9 +2794,6 @@ export default abstract class Server< postponed: metadata.postponed, status: res.statusCode, segmentData: undefined, - immutableResumeDataCache: metadata.immutableResumeDataCache - ? metadata.immutableResumeDataCache - : undefined, } satisfies CachedAppPageValue, revalidate: metadata.revalidate, isFallback: !!fallbackRouteParams, @@ -2979,7 +2964,6 @@ export default abstract class Server< // router. return doRender({ postponed: undefined, - immutableResumeDataCache: undefined, fallbackRouteParams: null, }) }, @@ -3008,7 +2992,6 @@ export default abstract class Server< // We pass `undefined` as rendering a fallback isn't resumed // here. postponed: undefined, - immutableResumeDataCache: undefined, fallbackRouteParams: // If we're in production of we're debugging the fallback // shell then we should postpone when dynamic params are @@ -3079,7 +3062,6 @@ export default abstract class Server< // Perform the render. const result = await doRender({ postponed, - immutableResumeDataCache: undefined, fallbackRouteParams, }) if (!result) return null @@ -3194,7 +3176,6 @@ export default abstract class Server< // fallbackRouteParams. fallbackRouteParams: null, postponed: undefined, - immutableResumeDataCache: undefined, }), { routeKind: RouteKind.APP_PAGE, @@ -3543,7 +3524,6 @@ export default abstract class Server< // we've already chained the transformer's readable to the render result. doRender({ postponed: cachedData.postponed, - immutableResumeDataCache: cachedData.immutableResumeDataCache, // This is a resume render, not a fallback render, so we don't need to // set this. fallbackRouteParams: null, 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 c3845499eb235e..4ef4f24f8cf9a9 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 @@ -13,18 +13,12 @@ import { NEXT_CACHE_TAGS_HEADER, NEXT_DATA_SUFFIX, NEXT_META_SUFFIX, - NEXT_STATIC_DATA_CACHE_SUFFIX, RSC_PREFETCH_SUFFIX, RSC_SEGMENT_SUFFIX, RSC_SEGMENTS_DIR_SUFFIX, RSC_SUFFIX, } from '../../../lib/constants' import { tagsManifest } from './tags-manifest.external' -import { - parseResumeDataCache, - stringifyResumeDataCache, -} from '../../resume-data-cache/serialization' -import type { ImmutableResumeDataCache } from '../../resume-data-cache/resume-data-cache' type FileSystemCacheContext = Omit< CacheHandlerContext, @@ -137,16 +131,6 @@ export default class FileSystemCache implements CacheHandler { ) ) - let resumeDataCache: ImmutableResumeDataCache | undefined - try { - resumeDataCache = await parseResumeDataCache( - await this.fs.readFile( - filePath.replace(/\.body$/, NEXT_STATIC_DATA_CACHE_SUFFIX), - 'utf8' - ) - ) - } catch {} - const cacheEntry: CacheHandlerValue = { lastModified: mtime.getTime(), value: { @@ -154,7 +138,6 @@ export default class FileSystemCache implements CacheHandler { body: fileData, headers: meta.headers, status: meta.status, - immutableResumeDataCache: resumeDataCache, }, } return cacheEntry @@ -253,16 +236,6 @@ export default class FileSystemCache implements CacheHandler { ) } - let immutableResumeDataCache: ImmutableResumeDataCache | undefined - try { - immutableResumeDataCache = await parseResumeDataCache( - await this.fs.readFile( - filePath.replace(/\.html$/, NEXT_STATIC_DATA_CACHE_SUFFIX), - 'utf8' - ) - ) - } catch {} - data = { lastModified: mtime.getTime(), value: { @@ -273,7 +246,6 @@ export default class FileSystemCache implements CacheHandler { headers: meta?.headers, status: meta?.status, segmentData: maybeSegmentData, - immutableResumeDataCache, }, } } else if (kind === IncrementalCacheKind.PAGES) { @@ -400,14 +372,6 @@ export default class FileSystemCache implements CacheHandler { filePath.replace(/\.body$/, NEXT_META_SUFFIX), JSON.stringify(meta, null, 2) ) - - // TODO: write out the static data cache - if (data.immutableResumeDataCache) { - await this.fs.writeFile( - filePath.replace(/\.body$/, NEXT_STATIC_DATA_CACHE_SUFFIX), - await stringifyResumeDataCache(data.immutableResumeDataCache) - ) - } } else if ( data.kind === CachedRouteKind.PAGES || data.kind === CachedRouteKind.APP_PAGE @@ -451,13 +415,6 @@ export default class FileSystemCache implements CacheHandler { htmlPath.replace(/\.html$/, NEXT_META_SUFFIX), JSON.stringify(meta) ) - - if (data.immutableResumeDataCache) { - await this.fs.writeFile( - htmlPath.replace(/\.html$/, NEXT_STATIC_DATA_CACHE_SUFFIX), - await stringifyResumeDataCache(data.immutableResumeDataCache) - ) - } } } else if (data.kind === CachedRouteKind.FETCH) { const filePath = this.getFilePath(key, IncrementalCacheKind.FETCH) diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index c5f5d76c643574..ef13be991d9271 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -37,9 +37,10 @@ export type AppPageRenderResultMetadata = { segmentFlightData?: Map /** - * Generated during a prerender, this is used for resuming pages. + * In development, the cache is warmed up before the render. This is attached + * to the metadata so that it can be used during the render. */ - immutableResumeDataCache?: ImmutableResumeDataCache + devWarmupImmutableResumeDataCache?: ImmutableResumeDataCache } export type PagesRenderResultMetadata = { diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 3638fa886772f2..0ea4afd7892490 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -2,7 +2,6 @@ import type { OutgoingHttpHeaders } from 'http' import type RenderResult from '../render-result' import type { Revalidate } from '../lib/revalidate' import type { RouteKind } from '../route-kind' -import type { ImmutableResumeDataCache } from '../resume-data-cache/resume-data-cache' export interface ResponseCacheBase { get( @@ -79,7 +78,6 @@ export interface CachedAppPageValue { postponed: string | undefined headers: OutgoingHttpHeaders | undefined segmentData: { [segmentPath: string]: string } | undefined - immutableResumeDataCache: ImmutableResumeDataCache | undefined } export interface CachedPageValue { @@ -99,7 +97,6 @@ export interface CachedRouteValue { body: Buffer status: number headers: OutgoingHttpHeaders - immutableResumeDataCache: ImmutableResumeDataCache | undefined } export interface CachedImageValue { @@ -122,7 +119,6 @@ export interface IncrementalCachedAppPageValue { postponed: string | undefined status: number | undefined segmentData: { [segmentPath: string]: string } | undefined - immutableResumeDataCache: ImmutableResumeDataCache | undefined } export interface IncrementalCachedPageValue { diff --git a/packages/next/src/server/response-cache/utils.ts b/packages/next/src/server/response-cache/utils.ts index 8a0dc82369d55b..7e2bedf60251d2 100644 --- a/packages/next/src/server/response-cache/utils.ts +++ b/packages/next/src/server/response-cache/utils.ts @@ -33,8 +33,6 @@ export async function fromResponseCacheEntry( headers: cacheEntry.value.headers, status: cacheEntry.value.status, segmentData: cacheEntry.value.segmentData, - immutableResumeDataCache: - cacheEntry.value.immutableResumeDataCache, } : cacheEntry.value, } @@ -74,7 +72,6 @@ export async function toResponseCacheEntry( status: response.value.status, postponed: response.value.postponed, segmentData: response.value.segmentData, - immutableResumeDataCache: response.value.immutableResumeDataCache, } satisfies CachedAppPageValue) : response.value, } diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index b1a50745bac7a8..4ad14467a47483 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -13,6 +13,7 @@ interface CacheStore { set(key: string, value: T): void entries(): Promise<[string, S][]> seal(): void + readonly size: number } /** @@ -45,6 +46,10 @@ export class FetchCacheStore implements CacheStore { this.store.set(key, value) } + public get size(): number { + return this.store.size + } + public get(key: string): CachedFetchValue | undefined { return this.store.get(key) } @@ -122,6 +127,10 @@ export class UseCacheCacheStore return this.store.get(key) } + public get size(): number { + return this.store.size + } + public async entries(): Promise<[string, CacheCacheStoreSerialized][]> { return Promise.all( Array.from(this.store.entries()).map(([key, value]) => { diff --git a/packages/next/src/server/resume-data-cache/serialization.test.ts b/packages/next/src/server/resume-data-cache/serialization.test.ts new file mode 100644 index 00000000000000..4beb37cb9376d2 --- /dev/null +++ b/packages/next/src/server/resume-data-cache/serialization.test.ts @@ -0,0 +1,42 @@ +import { stringifyResumeDataCache, parseResumeDataCache } from './serialization' +import { + createMutableResumeDataCache, + sealResumeDataCache, +} from './resume-data-cache' +import { streamFromString } from '../stream-utils/node-web-streams-helper' + +describe('stringifyResumeDataCache', () => { + it('serializes an empty cache', async () => { + const cache = sealResumeDataCache(createMutableResumeDataCache()) + expect(await stringifyResumeDataCache(cache)).toBe('null') + }) + + it('serializes a cache with a single entry', async () => { + const cache = createMutableResumeDataCache() + cache.cache.set( + 'key', + Promise.resolve({ + value: streamFromString('value'), + tags: [], + stale: 0, + timestamp: 0, + expire: 0, + revalidate: 0, + }) + ) + + expect( + await stringifyResumeDataCache(sealResumeDataCache(cache)) + ).toMatchInlineSnapshot( + `"{"store":{"fetch":{},"cache":{"key":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}}}}"` + ) + }) +}) + +describe('parseResumeDataCache', () => { + it('parses an empty cache', () => { + expect(parseResumeDataCache('null')).toEqual( + sealResumeDataCache(createMutableResumeDataCache()) + ) + }) +}) diff --git a/packages/next/src/server/resume-data-cache/serialization.ts b/packages/next/src/server/resume-data-cache/serialization.ts index cc16e1e500244b..0507440a85bd5c 100644 --- a/packages/next/src/server/resume-data-cache/serialization.ts +++ b/packages/next/src/server/resume-data-cache/serialization.ts @@ -2,7 +2,6 @@ import type { ImmutableResumeDataCache } from './resume-data-cache' import { UseCacheCacheStore, FetchCacheStore } from './cache-store' type ResumeStoreSerialized = { - version: 1 store: { cache: { [key: string]: any @@ -19,29 +18,33 @@ type ResumeStoreSerialized = { export async function stringifyResumeDataCache( resumeDataCache: ImmutableResumeDataCache ): Promise { + if (resumeDataCache.fetch.size === 0 && resumeDataCache.cache.size === 0) { + return 'null' + } + const json: ResumeStoreSerialized = { - version: 1, store: { fetch: Object.fromEntries(await resumeDataCache.fetch.entries()), cache: Object.fromEntries(await resumeDataCache.cache.entries()), }, } - return JSON.stringify(json, null, 2) + return JSON.stringify(json) } /** * Parses a serialized resume data cache into an immutable version of the cache. * This cache cannot be mutated further, and is returned sealed. */ -export async function parseResumeDataCache( - text: string -): Promise { - const json: ResumeStoreSerialized = JSON.parse(text) - if (json.version !== 1) { - throw new Error(`Unsupported version: ${json.version}`) +export function parseResumeDataCache(text: string): ImmutableResumeDataCache { + if (text === 'null') { + return { + cache: new UseCacheCacheStore([]), + fetch: new FetchCacheStore([]), + } } + const json: ResumeStoreSerialized = JSON.parse(text) return { cache: new UseCacheCacheStore(Object.entries(json.store.cache)), fetch: new FetchCacheStore(Object.entries(json.store.fetch)), diff --git a/test/unit/incremental-cache/file-system-cache.test.ts b/test/unit/incremental-cache/file-system-cache.test.ts index 6db68cd7bec8a0..cc55ec2e642239 100644 --- a/test/unit/incremental-cache/file-system-cache.test.ts +++ b/test/unit/incremental-cache/file-system-cache.test.ts @@ -32,7 +32,6 @@ describe('FileSystemCache', () => { }, status: 200, kind: CachedRouteKind.APP_ROUTE, - immutableResumeDataCache: null, }, {} )