Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Segment Cache] Send <head> during route prefetch #72890

Open
wants to merge 2 commits into
base: canary
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions packages/next/src/server/app-render/collect-segment-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ManifestNode } from '../../build/webpack/plugins/flight-manifest-p
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFromReadableStream } from 'react-server-dom-webpack/client.edge'
// eslint-disable-next-line import/no-extraneous-dependencies
import { renderToReadableStream } from 'react-server-dom-webpack/server.edge'
import { prerender } from 'react-server-dom-webpack/static.edge'

import {
streamFromBuffer,
Expand All @@ -23,6 +23,7 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-context.shar
// it can fetch any actual segment data.
type RootTreePrefetch = {
tree: TreePrefetch
head: React.ReactNode | null
staleTime: number
}

Expand Down Expand Up @@ -81,12 +82,22 @@ export async function collectSegmentData(
await waitAtLeastOneReactRenderTask()
} catch {}

// Create an abort controller that we'll use to stop the stream.
const abortController = new AbortController()
const onCompletedProcessingRouteTree = async () => {
// Since all we're doing is decoding and re-encoding a cached prerender, if
// serializing the stream takes longer than a microtask, it must because of
// hanging promises caused by dynamic data.
await waitAtLeastOneReactRenderTask()
abortController.abort()
}

// Generate a stream for the route tree prefetch. While we're walking the
// tree, we'll also spawn additional tasks to generate the segment prefetches.
// The promises for these tasks are pushed to a mutable array that we will
// await once the route tree is fully rendered.
const segmentTasks: Array<Promise<[string, Buffer]>> = []
const treeStream = await renderToReadableStream(
const { prelude: treeStream } = await prerender(
// RootTreePrefetch is not a valid return type for a React component, but
// we need to use a component so that when we decode the original stream
// inside of it, the side effects are transferred to the new stream.
Expand All @@ -97,13 +108,11 @@ export async function collectSegmentData(
clientModules={clientModules}
staleTime={staleTime}
segmentTasks={segmentTasks}
onCompletedProcessingRouteTree={onCompletedProcessingRouteTree}
/>,
clientModules,
{
// Unlike when rendering the segment streams, we do not pass an abort
// controller here. There's nothing dynamic in the prefetch metadata; we
// will always render the result. We do still have to account for hanging
// promises, but we use a different strategy. See PrefetchTreeData.
signal: abortController.signal,
onError() {
// Ignore any errors. These would have already been reported when
// we created the full page data.
Expand Down Expand Up @@ -131,20 +140,22 @@ async function PrefetchTreeData({
clientModules,
staleTime,
segmentTasks,
onCompletedProcessingRouteTree,
}: {
fullPageDataBuffer: Buffer
serverConsumerManifest: any
clientModules: ManifestNode
staleTime: number
segmentTasks: Array<Promise<[string, Buffer]>>
onCompletedProcessingRouteTree: () => void
}): Promise<RootTreePrefetch | null> {
// We're currently rendering a Flight response for the route tree prefetch.
// Inside this component, decode the Flight stream for the whole page. This is
// a hack to transfer the side effects from the original Flight stream (e.g.
// Float preloads) onto the Flight stream for the tree prefetch.
// TODO: React needs a better way to do this. Needed for Server Actions, too.
const initialRSCPayload: InitialRSCPayload = await createFromReadableStream(
streamFromBuffer(fullPageDataBuffer),
createUnclosingPrefetchStream(streamFromBuffer(fullPageDataBuffer)),
{
serverConsumerManifest,
}
Expand All @@ -161,6 +172,7 @@ async function PrefetchTreeData({
}
const flightRouterState: FlightRouterState = flightDataPaths[0][0]
const seedData: CacheNodeSeedData = flightDataPaths[0][1]
const head: React.ReactNode | null = flightDataPaths[0][2]

// Compute the route metadata tree by traversing the FlightRouterState. As we
// walk the tree, we will also spawn a task to produce a prefetch response for
Expand All @@ -176,9 +188,15 @@ async function PrefetchTreeData({
segmentTasks
)

// Notify the abort controller that we're done processing the route tree.
// Anything async that happens after this point must be due to hanging
// promises in the original stream.
onCompletedProcessingRouteTree()

// Render the route tree to a special `/_tree` segment.
const treePrefetch: RootTreePrefetch = {
tree,
head,
staleTime,
}
return treePrefetch
Expand Down Expand Up @@ -280,7 +298,7 @@ async function renderSegmentPrefetch(
// caused by dynamic data. Abort the stream at the end of the current task.
const abortController = new AbortController()
waitAtLeastOneReactRenderTask().then(() => abortController.abort())
const segmentStream = await renderToReadableStream(
const { prelude: segmentStream } = await prerender(
segmentPrefetch,
clientModules,
{
Expand Down Expand Up @@ -420,3 +438,36 @@ async function createSegmentAccessToken(

return hashHex
}

function createUnclosingPrefetchStream(
originalFlightStream: ReadableStream<Uint8Array>
): ReadableStream<Uint8Array> {
// When PPR is enabled, prefetch streams may contain references that never
// resolve, because that's how we encode dynamic data access. In the decoded
// object returned by the Flight client, these are reified into hanging
// promises that suspend during render, which is effectively what we want.
// The UI resolves when it switches to the dynamic data stream
// (via useDeferredValue(dynamic, static)).
//
// However, the Flight implementation currently errors if the server closes
// the response before all the references are resolved. As a cheat to work
// around this, we wrap the original stream in a new stream that never closes,
// and therefore doesn't error.
const reader = originalFlightStream.getReader()
return new ReadableStream({
async pull(controller) {
while (true) {
const { done, value } = await reader.read()
if (!done) {
// Pass to the target stream and keep consuming the Flight response
// from the server.
controller.enqueue(value)
continue
}
// The server stream has closed. Exit, but intentionally do not close
// the target stream.
return
}
},
})
}
Loading