@@ -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({
453452async 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
555567function createErrorContext (
@@ -829,7 +841,6 @@ async function generateDynamicFlightRenderResultWithStagesInDev(
829841
830842async 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
10411167async 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' &&
0 commit comments