Skip to content

Commit cf18481

Browse files
feedthejimclaude
andcommitted
feat: eager HTML cache regeneration on updateTag/refresh
When updateTag() or refresh() is called in a server action, this change ensures: 1. User A (action caller) sees fresh data immediately (read-your-own-writes) 2. User B (cold visitor) gets the same cached data from HTML cache 3. Both users see identical data from a single render Implementation uses client-side stream splitting: - Server concatenates action result stream and page stream with a boundary - Client splits on boundary, processes each stream independently - Results combined at JS object level Files changed: - lib/constants.ts: Added FLIGHT_STREAM_BOUNDARY constant - node-web-streams-helper.ts: Added concatenateFlightStreams() - action-handler.ts: Trigger logic for updateTag/refresh - fetch-server-response.ts: Added splitFlightStreams() - server-action-reducer.ts: Handle split streams on client - render-result.ts: Added toReadableStream() method 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 6e56ad5 commit cf18481

File tree

15 files changed

+668
-15
lines changed

15 files changed

+668
-15
lines changed

packages/next/src/client/components/app-router-headers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ export const NEXT_HTML_REQUEST_ID_HEADER = 'x-nextjs-html-request-id' as const
3737

3838
// TODO: Should this include nextjs in the name, like the others?
3939
export const NEXT_ACTION_REVALIDATED_HEADER = 'x-action-revalidated' as const
40+
41+
// Header to indicate split flight response (action result + page data with boundary)
42+
export const NEXT_SPLIT_FLIGHT_HEADER = 'x-next-split-flight' as const

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,77 @@ function createUnclosingPrefetchStream(
503503
},
504504
})
505505
}
506+
507+
/**
508+
* Splits a Flight stream that contains two concatenated streams separated by a boundary.
509+
* Used for split flight responses where action result and page data are sent together.
510+
*
511+
* @param body - The response body containing [action stream][boundary][page stream]
512+
* @param boundary - The boundary string that separates the two streams
513+
* @returns Two separate streams that can be processed independently
514+
*/
515+
export async function splitFlightStreams(
516+
body: ReadableStream<Uint8Array>,
517+
boundary: string
518+
): Promise<{
519+
actionStream: ReadableStream<Uint8Array>
520+
pageStream: ReadableStream<Uint8Array>
521+
}> {
522+
const reader = body.getReader()
523+
const decoder = new TextDecoder()
524+
const encoder = new TextEncoder()
525+
const boundaryBytes = encoder.encode(boundary)
526+
527+
let buffer = new Uint8Array(0)
528+
let boundaryIndex = -1
529+
530+
// Read until we find the boundary
531+
while (boundaryIndex === -1) {
532+
const { done, value } = await reader.read()
533+
if (done) {
534+
throw new Error('Split flight boundary not found in response')
535+
}
536+
537+
// Append to buffer
538+
const newBuffer = new Uint8Array(buffer.length + value.length)
539+
newBuffer.set(buffer)
540+
newBuffer.set(value, buffer.length)
541+
buffer = newBuffer
542+
543+
// Search for boundary in the accumulated buffer
544+
const text = decoder.decode(buffer)
545+
boundaryIndex = text.indexOf(boundary)
546+
}
547+
548+
// Split buffer at boundary
549+
const actionBytes = buffer.slice(0, boundaryIndex)
550+
const pageStartBytes = buffer.slice(boundaryIndex + boundaryBytes.length)
551+
552+
// Create action stream from the buffered action bytes
553+
const actionStream = new ReadableStream<Uint8Array>({
554+
start(controller) {
555+
controller.enqueue(actionBytes)
556+
controller.close()
557+
},
558+
})
559+
560+
// Create page stream: starts with buffered remainder, then pipes rest of original stream
561+
const pageStream = new ReadableStream<Uint8Array>({
562+
async start(controller) {
563+
// First enqueue the buffered bytes after the boundary
564+
if (pageStartBytes.length > 0) {
565+
controller.enqueue(pageStartBytes)
566+
}
567+
568+
// Then pipe through the rest of the original stream
569+
while (true) {
570+
const { done, value } = await reader.read()
571+
if (done) break
572+
controller.enqueue(value)
573+
}
574+
controller.close()
575+
},
576+
})
577+
578+
return { actionStream, pageStream }
579+
}

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import {
1313
NEXT_URL,
1414
RSC_CONTENT_TYPE_HEADER,
1515
NEXT_REQUEST_ID_HEADER,
16+
NEXT_SPLIT_FLIGHT_HEADER,
1617
} from '../../app-router-headers'
18+
import { FLIGHT_STREAM_BOUNDARY } from '../../../../lib/constants'
19+
import {
20+
splitFlightStreams,
21+
createFromNextReadableStream,
22+
} from '../fetch-server-response'
1723
import { UnrecognizedActionError } from '../../unrecognized-action-error'
1824

1925
// TODO: Explicitly import from client.browser
@@ -211,23 +217,55 @@ async function fetchServerAction(
211217
let actionFlightDataCouldBeIntercepted: FetchServerActionResult['actionFlightDataCouldBeIntercepted']
212218

213219
if (isRscResponse) {
214-
const response: ActionFlightResponse = await createFromFetch(
215-
Promise.resolve(res),
216-
{
217-
callServer,
218-
findSourceMapURL,
219-
temporaryReferences,
220-
debugChannel: createDebugChannel && createDebugChannel(headers),
220+
// Check if this is a split flight response (action result + page data separated by boundary)
221+
const isSplitFlight = res.headers.get(NEXT_SPLIT_FLIGHT_HEADER) === '1'
222+
223+
if (isSplitFlight && res.body) {
224+
// Split the response body on boundary and process each stream independently
225+
const { actionStream, pageStream } = await splitFlightStreams(
226+
res.body,
227+
FLIGHT_STREAM_BOUNDARY
228+
)
229+
230+
// Process each stream independently using createFromNextReadableStream
231+
// We cast to RequestHeaders since headers is a Record<string, string>
232+
const requestHeaders =
233+
headers as import('../fetch-server-response').RequestHeaders
234+
const actionResponse: ActionFlightResponse =
235+
await createFromNextReadableStream(actionStream, requestHeaders)
236+
const pageResponse: ActionFlightResponse =
237+
await createFromNextReadableStream(pageStream, requestHeaders)
238+
239+
// Combine at JS object level
240+
// Action result comes from the action stream
241+
actionResult = redirectLocation ? undefined : actionResponse.a
242+
// Flight data comes from the page stream (internal request result)
243+
const maybeFlightData = normalizeFlightData(pageResponse.f)
244+
if (maybeFlightData !== '') {
245+
actionFlightData = maybeFlightData
246+
actionFlightDataRenderedSearch = pageResponse.q as NormalizedSearch
247+
actionFlightDataCouldBeIntercepted = pageResponse.i
221248
}
222-
)
249+
} else {
250+
// Standard single-stream response
251+
const response: ActionFlightResponse = await createFromFetch(
252+
Promise.resolve(res),
253+
{
254+
callServer,
255+
findSourceMapURL,
256+
temporaryReferences,
257+
debugChannel: createDebugChannel && createDebugChannel(headers),
258+
}
259+
)
223260

224-
// An internal redirect can send an RSC response, but does not have a useful `actionResult`.
225-
actionResult = redirectLocation ? undefined : response.a
226-
const maybeFlightData = normalizeFlightData(response.f)
227-
if (maybeFlightData !== '') {
228-
actionFlightData = maybeFlightData
229-
actionFlightDataRenderedSearch = response.q as NormalizedSearch
230-
actionFlightDataCouldBeIntercepted = response.i
261+
// An internal redirect can send an RSC response, but does not have a useful `actionResult`.
262+
actionResult = redirectLocation ? undefined : response.a
263+
const maybeFlightData = normalizeFlightData(response.f)
264+
if (maybeFlightData !== '') {
265+
actionFlightData = maybeFlightData
266+
actionFlightDataRenderedSearch = response.q as NormalizedSearch
267+
actionFlightDataCouldBeIntercepted = response.i
268+
}
231269
}
232270
} else {
233271
// An external redirect doesn't contain RSC data.

packages/next/src/lib/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER =
2626

2727
export const NEXT_RESUME_HEADER = 'next-resume'
2828

29+
// Header to indicate split flight streams (action result + page data)
30+
export const NEXT_SPLIT_FLIGHT_HEADER = 'x-next-split-flight'
31+
32+
// Boundary to separate action result stream from page stream in split flight responses
33+
export const FLIGHT_STREAM_BOUNDARY = '__NEXT_FLIGHT_BOUNDARY__\n'
34+
2935
// if these change make sure we update the related
3036
// documentation as well
3137
export const NEXT_CACHE_TAG_MAX_ITEMS = 128

packages/next/src/server/app-render/action-handler.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ import {
4343
JSON_CONTENT_TYPE_HEADER,
4444
NEXT_CACHE_REVALIDATED_TAGS_HEADER,
4545
NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
46+
NEXT_SPLIT_FLIGHT_HEADER,
47+
FLIGHT_STREAM_BOUNDARY,
4648
} from '../../lib/constants'
49+
import { concatenateFlightStreams } from '../stream-utils/node-web-streams-helper'
50+
import { getClientReferenceManifest } from './manifests-singleton'
4751
import { getServerActionRequestMetadata } from '../lib/server-action-request-meta'
4852
import { isCsrfOriginAllowed } from './csrf-protection'
4953
import { warn } from '../../build/output/log'
@@ -70,6 +74,13 @@ import {
7074
ActionDidRevalidateStaticAndDynamic,
7175
} from '../../shared/lib/action-revalidation-kind'
7276

77+
// Used for filtering stack frames in renderToReadableStream
78+
const filterStackFrame =
79+
process.env.NODE_ENV !== 'production'
80+
? (require('../lib/source-maps') as typeof import('../lib/source-maps'))
81+
.filterStackFrameDEV
82+
: undefined
83+
7384
/**
7485
* Checks if the app has any server actions defined in any runtime.
7586
*/
@@ -444,6 +455,100 @@ async function createRedirectRenderResult(
444455
return RenderResult.EMPTY
445456
}
446457

458+
/**
459+
* Creates a render result by making an internal request to the current page.
460+
* This is used for updateTag/refresh to ensure the HTML cache gets populated
461+
* with the same data that the action caller sees (read-your-own-writes consistency).
462+
*
463+
* Similar to createRedirectRenderResult but for the current page instead of a redirect URL.
464+
*/
465+
async function createInternalRequestRenderResult(
466+
req: BaseNextRequest,
467+
res: BaseNextResponse,
468+
originalHost: Host,
469+
pagePath: string,
470+
basePath: string,
471+
workStore: WorkStore
472+
): Promise<RenderResult | null> {
473+
if (!originalHost) {
474+
return null
475+
}
476+
477+
const forwardedHeaders = getForwardedHeaders(req, res)
478+
forwardedHeaders.set(RSC_HEADER, '1')
479+
480+
const proto =
481+
getRequestMeta(req, 'initProtocol')?.replace(/:+$/, '') || 'https'
482+
483+
// For standalone or the serverful mode, use the internal origin directly
484+
// other than the host headers from the request.
485+
const origin =
486+
process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${originalHost.value}`
487+
488+
const fetchUrl = new URL(`${origin}${basePath}${pagePath}`)
489+
490+
if (workStore.pendingRevalidatedTags?.length) {
491+
forwardedHeaders.set(
492+
NEXT_CACHE_REVALIDATED_TAGS_HEADER,
493+
workStore.pendingRevalidatedTags.map((item) => item.tag).join(',')
494+
)
495+
forwardedHeaders.set(
496+
NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
497+
workStore.incrementalCache?.prerenderManifest?.preview?.previewModeId ||
498+
''
499+
)
500+
}
501+
502+
// Keep the router state tree header for partial rendering - Next.js's
503+
// group revalidation will handle writing the full HTML cache
504+
// Remove action header since this is now a page render request
505+
forwardedHeaders.delete(ACTION_HEADER)
506+
507+
try {
508+
setCacheBustingSearchParam(fetchUrl, {
509+
[NEXT_ROUTER_PREFETCH_HEADER]: forwardedHeaders.get(
510+
NEXT_ROUTER_PREFETCH_HEADER
511+
)
512+
? ('1' as const)
513+
: undefined,
514+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]:
515+
forwardedHeaders.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER) ?? undefined,
516+
[NEXT_ROUTER_STATE_TREE_HEADER]:
517+
forwardedHeaders.get(NEXT_ROUTER_STATE_TREE_HEADER) ?? undefined,
518+
[NEXT_URL]: forwardedHeaders.get(NEXT_URL) ?? undefined,
519+
})
520+
521+
const response = await fetch(fetchUrl, {
522+
method: 'GET',
523+
headers: forwardedHeaders,
524+
next: {
525+
// @ts-ignore
526+
internal: 1,
527+
},
528+
})
529+
530+
if (
531+
response.headers.get('content-type')?.startsWith(RSC_CONTENT_TYPE_HEADER)
532+
) {
533+
// Copy headers from the response
534+
for (const [key, value] of response.headers) {
535+
if (!actionsForbiddenHeaders.includes(key)) {
536+
res.setHeader(key, value)
537+
}
538+
}
539+
540+
return new FlightRenderResult(response.body!)
541+
} else {
542+
// Since we aren't consuming the response body, we cancel it to avoid memory leaks
543+
response.body?.cancel()
544+
}
545+
} catch (err) {
546+
console.error('Failed to get internal request response for updateTag:', err)
547+
}
548+
549+
return null
550+
}
551+
447552
// Used to compare Host header and Origin header.
448553
const enum HostType {
449554
XForwardedHost = 'x-forwarded-host',
@@ -1084,6 +1189,64 @@ export async function handleAction({
10841189
addRevalidationHeader(res, { workStore, requestStore })
10851190
})
10861191

1192+
// For updateTag() or refresh() calls, use internal request to ensure
1193+
// the HTML cache gets populated with the same data the action caller sees.
1194+
// This provides read-your-own-writes consistency between User A (action caller)
1195+
// and User B (subsequent cold visitor).
1196+
const hasUpdateTagCalls = workStore.pendingRevalidatedTags?.some(
1197+
(item) => item.profile === undefined
1198+
)
1199+
const hasRefreshCall =
1200+
workStore.pathWasRevalidated !== undefined &&
1201+
workStore.pathWasRevalidated !== ActionDidNotRevalidate
1202+
1203+
const shouldUseSplitFlightResponse = hasUpdateTagCalls || hasRefreshCall
1204+
1205+
if (
1206+
isFetchAction &&
1207+
shouldUseSplitFlightResponse &&
1208+
!actionWasForwarded
1209+
) {
1210+
// Make an internal request to get the page render stream.
1211+
// This ensures the same data is used for both action response and HTML cache.
1212+
const pageResult = await createInternalRequestRenderResult(
1213+
req,
1214+
res,
1215+
host,
1216+
workStore.route,
1217+
ctx.renderOpts.basePath,
1218+
workStore
1219+
)
1220+
1221+
if (pageResult) {
1222+
// Encode the action result as a separate Flight stream
1223+
const { clientModules } = getClientReferenceManifest()
1224+
const actionResultPayload = { a: Promise.resolve(actionResult) }
1225+
const actionResultStream = ComponentMod.renderToReadableStream(
1226+
actionResultPayload,
1227+
clientModules,
1228+
{ temporaryReferences, filterStackFrame }
1229+
)
1230+
1231+
// Set header to indicate split flight response
1232+
res.setHeader(NEXT_SPLIT_FLIGHT_HEADER, '1')
1233+
1234+
// Concatenate: [action result stream][BOUNDARY][page stream]
1235+
// Client will split on boundary and process each stream independently
1236+
const combinedStream = concatenateFlightStreams(
1237+
actionResultStream,
1238+
pageResult.toReadableStream(),
1239+
FLIGHT_STREAM_BOUNDARY
1240+
)
1241+
1242+
return {
1243+
type: 'done',
1244+
result: new FlightRenderResult(combinedStream),
1245+
}
1246+
}
1247+
// If internal request failed, fall through to regular generateFlight
1248+
}
1249+
10871250
// For form actions, we need to continue rendering the page.
10881251
if (isFetchAction) {
10891252
return {

packages/next/src/server/render-result.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ export default class RenderResult<
195195
return this.response
196196
}
197197

198+
/**
199+
* Returns a readable stream of the response.
200+
*/
201+
public toReadableStream(): ReadableStream<Uint8Array> {
202+
return this.readable
203+
}
204+
198205
/**
199206
* Returns a readable stream of the response.
200207
*/

0 commit comments

Comments
 (0)