Skip to content

Commit 2f86fba

Browse files
ztannergnoff
andauthored
[runtime prefetching]: fix runtime prefetching when deployed (#85595)
Runtime prefetching relies on the presence of headers to determine caching behavior: `x-nextjs-postponed` indicates if the response was partial (so it knows if it should trigger a dynamic request to retrieve the missing data), and `x-nextjs-stale-time` provided stale time information to the client router. This worked fine in `next start` but due to how this is modeled in our Vercel deployment adapter, it was not possible to set runtime headers after the initial headers have been sent, as these requests were routed to prerenders. As a result of this limitation, this moves both the isPartial flag and the stale time value into the payload body. The response is run through a transform stream that replaces a sentinel value (`"rp": [sentinel]`) with the final stale time/isPartial values after we determine them. This avoids regenerating the entire RSC payload while ensuring correct values are sent to the client. Both properties are only included in runtime prefetch responses. Closes NAR-494 --------- Co-authored-by: Josh Story <[email protected]>
1 parent e989fcb commit 2f86fba

File tree

5 files changed

+179
-37
lines changed

5 files changed

+179
-37
lines changed

packages/next/src/client/components/segment-cache/cache.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,7 +1654,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
16541654
const isResponsePartial =
16551655
fetchStrategy === FetchStrategy.PPRRuntime
16561656
? // A runtime prefetch may have holes.
1657-
!!response.headers.get(NEXT_DID_POSTPONE_HEADER)
1657+
serverData.rp?.[0] === true
16581658
: // Full and LoadingBoundary prefetches cannot have holes.
16591659
// (even if we did set the prefetch header, we only use this codepath for non-PPR-enabled routes)
16601660
false
@@ -1719,14 +1719,15 @@ function writeDynamicTreeResponseIntoCache(
17191719
}
17201720

17211721
const flightRouterState = flightData.tree
1722-
// TODO: Extract to function
1723-
const staleTimeHeaderSeconds = response.headers.get(
1724-
NEXT_ROUTER_STALE_TIME_HEADER
1725-
)
1726-
const staleTimeMs =
1727-
staleTimeHeaderSeconds !== null
1728-
? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
1729-
: STATIC_STALETIME_MS
1722+
// For runtime prefetches, stale time is in the payload at rp[1].
1723+
// For other responses, fall back to the header.
1724+
const staleTimeSeconds =
1725+
typeof serverData.rp?.[1] === 'number'
1726+
? serverData.rp[1]
1727+
: parseInt(response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER) ?? '', 10)
1728+
const staleTimeMs = !isNaN(staleTimeSeconds)
1729+
? getStaleTimeMs(staleTimeSeconds)
1730+
: STATIC_STALETIME_MS
17301731

17311732
// If the response contains dynamic holes, then we must conservatively assume
17321733
// that any individual segment might contain dynamic holes, and also the
@@ -1814,13 +1815,15 @@ function writeDynamicRenderResponseIntoCache(
18141815
return null
18151816
}
18161817

1817-
const staleTimeHeaderSeconds = response.headers.get(
1818-
NEXT_ROUTER_STALE_TIME_HEADER
1819-
)
1820-
const staleTimeMs =
1821-
staleTimeHeaderSeconds !== null
1822-
? getStaleTimeMs(parseInt(staleTimeHeaderSeconds, 10))
1823-
: STATIC_STALETIME_MS
1818+
// For runtime prefetches, stale time is in the payload at rp[1].
1819+
// For other responses, fall back to the header.
1820+
const staleTimeSeconds =
1821+
typeof serverData.rp?.[1] === 'number'
1822+
? serverData.rp[1]
1823+
: parseInt(response.headers.get(NEXT_ROUTER_STALE_TIME_HEADER) ?? '', 10)
1824+
const staleTimeMs = !isNaN(staleTimeSeconds)
1825+
? getStaleTimeMs(staleTimeSeconds)
1826+
: STATIC_STALETIME_MS
18241827
const staleAt = now + staleTimeMs
18251828

18261829
for (const flightData of flightDatas) {

packages/next/src/server/app-render/app-render.tsx

Lines changed: 152 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ import {
5757
RSC_HEADER,
5858
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
5959
NEXT_HMR_REFRESH_HASH_COOKIE,
60-
NEXT_DID_POSTPONE_HEADER,
6160
NEXT_REQUEST_ID_HEADER,
6261
NEXT_HTML_REQUEST_ID_HEADER,
6362
} from '../../client/components/app-router-headers'
@@ -453,8 +452,9 @@ function NonIndex({
453452
async function generateDynamicRSCPayload(
454453
ctx: AppRenderContext,
455454
options?: {
456-
actionResult: ActionResult
457-
skipFlight: boolean
455+
actionResult?: ActionResult
456+
skipFlight?: boolean
457+
runtimePrefetchSentinel?: number
458458
}
459459
): Promise<RSCPayload> {
460460
// Flight data that is going to be passed to the browser.
@@ -545,11 +545,23 @@ async function generateDynamicRSCPayload(
545545
}
546546

547547
// Otherwise, it's a regular RSC response.
548-
return {
548+
const baseResponse = {
549549
b: ctx.sharedContext.buildId,
550550
f: flightData,
551551
S: workStore.isStaticGeneration,
552552
}
553+
554+
// For runtime prefetches, we encode the stale time and isPartial flag in the response body
555+
// rather than relying on response headers. Both of these values will be transformed
556+
// by a transform stream before being sent to the client.
557+
if (options?.runtimePrefetchSentinel !== undefined) {
558+
return {
559+
...baseResponse,
560+
rp: [options.runtimePrefetchSentinel] as any,
561+
}
562+
}
563+
564+
return baseResponse
553565
}
554566

555567
function createErrorContext(
@@ -829,7 +841,6 @@ async function generateDynamicFlightRenderResultWithStagesInDev(
829841

830842
async function generateRuntimePrefetchResult(
831843
req: BaseNextRequest,
832-
res: BaseNextResponse,
833844
ctx: AppRenderContext,
834845
requestStore: RequestStore
835846
): Promise<RenderResult> {
@@ -851,7 +862,14 @@ async function generateRuntimePrefetchResult(
851862

852863
const metadata: AppPageRenderResultMetadata = {}
853864

854-
const generatePayload = () => generateDynamicRSCPayload(ctx, undefined)
865+
// Generate a random sentinel that will be used as a placeholder in the payload
866+
// and later replaced by the transform stream
867+
const runtimePrefetchSentinel = Math.floor(
868+
Math.random() * Number.MAX_SAFE_INTEGER
869+
)
870+
871+
const generatePayload = () =>
872+
generateDynamicRSCPayload(ctx, { runtimePrefetchSentinel })
855873

856874
const {
857875
componentMod: {
@@ -889,16 +907,13 @@ async function generateRuntimePrefetchResult(
889907
requestStore.headers,
890908
requestStore.cookies,
891909
requestStore.draftMode,
892-
onError
910+
onError,
911+
runtimePrefetchSentinel
893912
)
894913

895914
applyMetadataFromPrerenderResult(response, metadata, workStore)
896915
metadata.fetchMetrics = ctx.workStore.fetchMetrics
897916

898-
if (response.isPartial) {
899-
res.setHeader(NEXT_DID_POSTPONE_HEADER, '1')
900-
}
901-
902917
return new FlightRenderResult(response.result.prelude, metadata)
903918
}
904919

@@ -1037,6 +1052,117 @@ async function prospectiveRuntimeServerPrerender(
10371052
return null
10381053
}
10391054
}
1055+
/**
1056+
* Updates the runtime prefetch metadata in the RSC payload as it streams:
1057+
* "rp":[<sentinel>] -> "rp":[<isPartial>,<staleTime>]
1058+
*
1059+
* We use a transform stream to do this to avoid needing to trigger an additional render.
1060+
* A random sentinel number guarantees no collision with user data.
1061+
*/
1062+
function createRuntimePrefetchTransformStream(
1063+
sentinel: number,
1064+
isPartial: boolean,
1065+
staleTime: number
1066+
): TransformStream<Uint8Array, Uint8Array> {
1067+
const encoder = new TextEncoder()
1068+
1069+
// Search for: [<sentinel>]
1070+
// Replace with: [<isPartial>,<staleTime>]
1071+
const search = encoder.encode(`[${sentinel}]`)
1072+
const first = search[0]
1073+
const replace = encoder.encode(`[${isPartial},${staleTime}]`)
1074+
const searchLen = search.length
1075+
1076+
let currentChunk: Uint8Array | null = null
1077+
let found = false
1078+
1079+
function processChunk(
1080+
controller: TransformStreamDefaultController<Uint8Array>,
1081+
nextChunk: null | Uint8Array
1082+
) {
1083+
if (found) {
1084+
if (nextChunk) {
1085+
controller.enqueue(nextChunk)
1086+
}
1087+
return
1088+
}
1089+
1090+
if (currentChunk) {
1091+
// We can't search past the index that can contain a full match
1092+
let exclusiveUpperBound = currentChunk.length - (searchLen - 1)
1093+
if (nextChunk) {
1094+
// If we have any overflow bytes we can search up to the chunk's final byte
1095+
exclusiveUpperBound += Math.min(nextChunk.length, searchLen - 1)
1096+
}
1097+
if (exclusiveUpperBound < 1) {
1098+
// we can't match the current chunk.
1099+
controller.enqueue(currentChunk)
1100+
currentChunk = nextChunk // advance so we don't process this chunk again
1101+
return
1102+
}
1103+
1104+
let currentIndex = currentChunk.indexOf(first)
1105+
1106+
// check the current candidate match if it is within the bounds of our search space for the currentChunk
1107+
candidateLoop: while (
1108+
-1 < currentIndex &&
1109+
currentIndex < exclusiveUpperBound
1110+
) {
1111+
// We already know index 0 matches because we used indexOf to find the candidateIndex so we start at index 1
1112+
let matchIndex = 1
1113+
while (matchIndex < searchLen) {
1114+
const candidateIndex = currentIndex + matchIndex
1115+
const candidateValue =
1116+
candidateIndex < currentChunk.length
1117+
? currentChunk[candidateIndex]
1118+
: // if we ever hit this condition it is because there is a nextChunk we can read from
1119+
nextChunk![candidateIndex - currentChunk.length]
1120+
if (candidateValue !== search[matchIndex]) {
1121+
// No match, reset and continue the search from the next position
1122+
currentIndex = currentChunk.indexOf(first, currentIndex + 1)
1123+
continue candidateLoop
1124+
}
1125+
matchIndex++
1126+
}
1127+
// We found a complete match. currentIndex is our starting point to replace the value.
1128+
found = true
1129+
// enqueue everything up to the match
1130+
controller.enqueue(currentChunk.subarray(0, currentIndex))
1131+
// enqueue the replacement value
1132+
controller.enqueue(replace)
1133+
// If there are bytes in the currentChunk after the match enqueue them
1134+
if (currentIndex + searchLen < currentChunk.length) {
1135+
controller.enqueue(currentChunk.subarray(currentIndex + searchLen))
1136+
}
1137+
// If we have a next chunk we enqueue it now
1138+
if (nextChunk) {
1139+
// if replacement spills over to the next chunk we first exclude the replaced bytes
1140+
const overflowBytes = currentIndex + searchLen - currentChunk.length
1141+
const truncatedChunk =
1142+
overflowBytes > 0 ? nextChunk!.subarray(overflowBytes) : nextChunk
1143+
controller.enqueue(truncatedChunk)
1144+
}
1145+
// We are now in found mode and don't need to track currentChunk anymore
1146+
currentChunk = null
1147+
return
1148+
}
1149+
// No match found in this chunk, emit it and wait for the next one
1150+
controller.enqueue(currentChunk)
1151+
}
1152+
1153+
// Advance to the next chunk
1154+
currentChunk = nextChunk
1155+
}
1156+
1157+
return new TransformStream<Uint8Array, Uint8Array>({
1158+
transform(chunk, controller) {
1159+
processChunk(controller, chunk)
1160+
},
1161+
flush(controller) {
1162+
processChunk(controller, null)
1163+
},
1164+
})
1165+
}
10401166

10411167
async function finalRuntimeServerPrerender(
10421168
ctx: AppRenderContext,
@@ -1047,7 +1173,8 @@ async function finalRuntimeServerPrerender(
10471173
headers: PrerenderStoreModernRuntime['headers'],
10481174
cookies: PrerenderStoreModernRuntime['cookies'],
10491175
draftMode: PrerenderStoreModernRuntime['draftMode'],
1050-
onError: (err: unknown) => string | undefined
1176+
onError: (err: unknown) => string | undefined,
1177+
runtimePrefetchSentinel: number
10511178
) {
10521179
const { implicitTags, renderOpts } = ctx
10531180

@@ -1150,6 +1277,17 @@ async function finalRuntimeServerPrerender(
11501277
}
11511278
)
11521279

1280+
// Update the RSC payload stream to replace the sentinel with actual values.
1281+
// React has already serialized the payload with the sentinel, so we need to transform the stream.
1282+
const collectedStale = selectStaleTime(finalServerPrerenderStore.stale)
1283+
result.prelude = result.prelude.pipeThrough(
1284+
createRuntimePrefetchTransformStream(
1285+
runtimePrefetchSentinel,
1286+
serverIsDynamic,
1287+
collectedStale
1288+
)
1289+
)
1290+
11531291
return {
11541292
result,
11551293
// TODO(runtime-ppr): do we need to produce a digest map here?
@@ -1158,7 +1296,7 @@ async function finalRuntimeServerPrerender(
11581296
isPartial: serverIsDynamic,
11591297
collectedRevalidate: finalServerPrerenderStore.revalidate,
11601298
collectedExpire: finalServerPrerenderStore.expire,
1161-
collectedStale: selectStaleTime(finalServerPrerenderStore.stale),
1299+
collectedStale,
11621300
collectedTags: finalServerPrerenderStore.tags,
11631301
}
11641302
}
@@ -2001,7 +2139,7 @@ async function renderToHTMLOrFlightImpl(
20012139

20022140
if (isRSCRequest) {
20032141
if (isRuntimePrefetchRequest) {
2004-
return generateRuntimePrefetchResult(req, res, ctx, requestStore)
2142+
return generateRuntimePrefetchResult(req, ctx, requestStore)
20052143
} else {
20062144
if (
20072145
process.env.NODE_ENV === 'development' &&

packages/next/src/shared/lib/app-router-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ export type NavigationFlightResponse = {
300300
f: FlightData
301301
/** prerendered */
302302
S: boolean
303+
/** runtimePrefetch - [isPartial, staleTime]. Only present in runtime prefetch responses. */
304+
rp?: [boolean, number]
303305
}
304306

305307
// Response from `createFromFetch` for server actions. Action's flight data can be null

test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import type * as Playwright from 'playwright'
44
import { createRouterAct } from 'router-act'
55

66
describe('runtime prefetching', () => {
7-
const { next, isNextDev, isNextDeploy, skipped } = nextTestSetup({
7+
const { next, isNextDev, isNextDeploy } = nextTestSetup({
88
files: __dirname,
9-
// TODO (runtime-prefetching): investigate failures when deployed to Vercel.
10-
skipDeployment: true,
119
})
12-
if (isNextDev || skipped) {
10+
if (isNextDev) {
1311
it('is skipped', () => {})
1412
return
1513
}
@@ -475,7 +473,8 @@ describe('runtime prefetching', () => {
475473
await browser.back()
476474

477475
// wait a tick before navigating
478-
await waitFor(500)
476+
// TODO: Why does this need to be so long when deployed? What other signal do we have that we can wait on?
477+
await waitFor(2000)
479478

480479
// Navigate to the page
481480
await act(async () => {

test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type * as Playwright from 'playwright'
33
import { createRouterAct } from 'router-act'
44

55
describe('segment cache (staleness)', () => {
6-
const { next, isNextDev, isNextDeploy } = nextTestSetup({
6+
const { next, isNextDev } = nextTestSetup({
77
files: __dirname,
88
})
9-
if (isNextDev || isNextDeploy) {
9+
if (isNextDev) {
1010
test('disabled in development / deployment', () => {})
1111
return
1212
}

0 commit comments

Comments
 (0)