diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 472604e707b4e..60af7e14ae3a2 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -19,7 +19,10 @@ import type { RequestStore } from '../app-render/work-unit-async-storage.externa import type { NextParsedUrlQuery } from '../request-meta' import type { LoaderTree } from '../lib/app-dir-module' import type { AppPageModule } from '../route-modules/app-page/module' -import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import type { + ClientReferenceManifest, + ManifestNode, +} from '../../build/webpack/plugins/flight-manifest-plugin' import type { DeepReadonly } from '../../shared/lib/deep-readonly' import type { BaseNextRequest, BaseNextResponse } from '../base-http' import type { IncomingHttpHeaders } from 'http' @@ -1253,32 +1256,6 @@ async function renderToHTMLOrFlightImpl( } } - // Per-segment prefetch data - // - // All of the segments for a page are generated simultaneously, including - // during revalidations. This is to ensure consistency, because it's - // possible for a mismatch between a layout and page segment can cause the - // client to error during rendering. We want to preserve the ability of the - // client to recover from such a mismatch by re-requesting all the segments - // to get a consistent view of the page. - // - // TODO (Per Segment Prefetching): This is placeholder data. Populate with - // the actual data generated during prerender. - if (renderOpts.experimental.isRoutePPREnabled === true) { - const placeholder = Buffer.from( - 'TODO (Per Segment Prefetching): Not yet implemented\n' - ) - metadata.segmentFlightData = new Map([ - // Root segment - ['/', placeholder], - ['/blog', placeholder], - // TODO: Update the client to use the same encoding for segment paths that - // we use here, so we don't have to convert between them. Needs to be - // filesystem safe. - ['/blog/[post]-1-d', placeholder], - ]) - } - return new RenderResult(await streamToString(response.stream), options) } else { // We're rendering dynamically @@ -2720,7 +2697,14 @@ async function prerenderToStream( tracingMetadata: tracingMetadata, }) - metadata.flightData = await streamToBuffer(reactServerResult.asStream()) + const flightData = await streamToBuffer(reactServerResult.asStream()) + metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + finalAttemptRSCPayload, + flightData, + ComponentMod, + renderOpts + ) if (serverIsDynamic || clientIsDynamic) { if (postponed != null) { @@ -3171,9 +3155,16 @@ async function prerenderToStream( // const reactServerResult = // await createReactServerPrerenderResultFromRender(reactServerStream!) - metadata.flightData = await streamToBuffer( + const flightData = await streamToBuffer( serverPrerenderStreamResult.asStream() ) + metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + finalServerPayload, + flightData, + ComponentMod, + renderOpts + ) const getServerInsertedHTML = makeGetServerInsertedHTML({ polyfills, @@ -3296,6 +3287,12 @@ async function prerenderToStream( if (shouldGenerateStaticFlightData(workStore)) { metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + RSCPayload, + flightData, + ComponentMod, + renderOpts + ) } /** @@ -3475,7 +3472,14 @@ async function prerenderToStream( ) if (shouldGenerateStaticFlightData(workStore)) { - metadata.flightData = await streamToBuffer(reactServerResult.asStream()) + const flightData = await streamToBuffer(reactServerResult.asStream()) + metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + RSCPayload, + flightData, + ComponentMod, + renderOpts + ) } const getServerInsertedHTML = makeGetServerInsertedHTML({ @@ -3627,9 +3631,16 @@ async function prerenderToStream( }) if (shouldGenerateStaticFlightData(workStore)) { - metadata.flightData = await streamToBuffer( + const flightData = await streamToBuffer( reactServerPrerenderResult.asStream() ) + metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + errorRSCPayload, + flightData, + ComponentMod, + renderOpts + ) } const validateRootLayout = renderOpts.dev @@ -3756,3 +3767,63 @@ const getGlobalErrorStyles = async ( return globalErrorStyles } + +async function collectSegmentData( + rscPayload: InitialRSCPayload, + fullPageDataBuffer: Buffer, + ComponentMod: AppPageModule, + renderOpts: RenderOpts +): Promise | undefined> { + // Per-segment prefetch data + // + // All of the segments for a page are generated simultaneously, including + // during revalidations. This is to ensure consistency, because it's + // possible for a mismatch between a layout and page segment can cause the + // client to error during rendering. We want to preserve the ability of the + // client to recover from such a mismatch by re-requesting all the segments + // to get a consistent view of the page. + // + // For performance, we reuse the Flight output that was created when + // generating the initial page HTML. The Flight stream for the whole page is + // decomposed into a separate stream per segment. + + const clientReferenceManifest = renderOpts.clientReferenceManifest + if ( + !clientReferenceManifest || + renderOpts.experimental.isRoutePPREnabled !== true + ) { + return + } + + // FlightDataPath is an unsound type, hence the additional checks. + const flightDataPaths = rscPayload.f + if (flightDataPaths.length !== 1 && flightDataPaths[0].length !== 3) { + console.error( + 'Internal Next.js error: InitialRSCPayload does not match the expected ' + + 'shape for a prerendered page during segment prefetch generation.' + ) + return + } + const routeTree: FlightRouterState = flightDataPaths[0][0] + + // Manifest passed to the Flight client for reading the full-page Flight + // stream. Based off similar code in use-cache-wrapper.ts. + const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' + const serverConsumerManifest = { + // moduleLoading must be null because we don't want to trigger preloads of ClientReferences + // to be added to the consumer. Instead, we'll wait for any ClientReference to be emitted + // which themselves will handle the preloading. + moduleLoading: null, + moduleMap: isEdgeRuntime + ? clientReferenceManifest.edgeRscModuleMapping + : clientReferenceManifest.rscModuleMapping, + serverModuleMap: null, + } + + return await ComponentMod.collectSegmentData( + routeTree, + fullPageDataBuffer, + clientReferenceManifest.clientModules as ManifestNode, + serverConsumerManifest + ) +} diff --git a/packages/next/src/server/app-render/collect-segment-data.tsx b/packages/next/src/server/app-render/collect-segment-data.tsx new file mode 100644 index 0000000000000..7f51d178c8195 --- /dev/null +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -0,0 +1,319 @@ +import type { + CacheNodeSeedData, + FlightRouterState, + InitialRSCPayload, + Segment, +} from './types' +import type { ManifestNode } from '../../build/webpack/plugins/flight-manifest-plugin' + +// 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 { + streamFromBuffer, + streamToBuffer, +} from '../stream-utils/node-web-streams-helper' +import { UNDERSCORE_NOT_FOUND_ROUTE } from '../../api/constants' +import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler' +import type { LoadingModuleData } from '../../shared/lib/app-router-context.shared-runtime' + +export async function collectSegmentData( + routeTree: FlightRouterState, + fullPageDataBuffer: Buffer, + clientModules: ManifestNode, + serverConsumerManifest: any +): Promise> { + // Traverse the router tree. For each segment, decode the Flight stream for + // the page, pick out its segment data, and re-encode it to a new Flight + // stream. This will be served when performing a client-side prefetch. + + // Before we start, warm up the module cache by decoding the page data once. + // Then we can assume that any remaining async tasks that occur the next time + // are due to hanging promises caused by dynamic data access. Note we only + // have to do this once per page, not per individual segment. + // + // Based on similar strategy in warmFlightResponse. + try { + await createFromReadableStream(streamFromBuffer(fullPageDataBuffer), { + serverConsumerManifest, + }) + await waitAtLeastOneReactRenderTask() + } catch {} + + // A mutable map to collect the results as we traverse the route tree. + const segmentBufferMap = new Map() + // A mutable array to collect the promises for each segment stream, so that + // they can run in parallel. + const collectedTasks: Array> = [] + + collectSegmentDataImpl( + routeTree, + fullPageDataBuffer, + clientModules, + serverConsumerManifest, + [], + '', + segmentBufferMap, + collectedTasks + ) + + // This will resolve either after a microtask (if none of the segments + // have dynamic data) or in the next tick (because of the abort signal passed + // to renderToReadableStream). + await Promise.all(collectedTasks) + + return segmentBufferMap +} + +function collectSegmentDataImpl( + route: FlightRouterState, + fullPageDataBuffer: Buffer, + clientModules: ManifestNode, + serverConsumerManifest: any, + segmentPath: Array<[string, Segment]>, + segmentPathStr: string, + segmentBufferMap: Map, + collectedTasks: Array> +): void { + const children = route[1] + for (const parallelRouteKey in children) { + const childRoute = children[parallelRouteKey] + const childSegment = childRoute[0] + const childSegmentPath = segmentPath.concat([ + [parallelRouteKey, childSegment], + ]) + const childSegmentPathStr = + segmentPathStr + + '/' + + encodeChildSegmentAsFilesystemSafePathname(parallelRouteKey, childSegment) + collectSegmentDataImpl( + childRoute, + fullPageDataBuffer, + clientModules, + serverConsumerManifest, + childSegmentPath, + childSegmentPathStr, + segmentBufferMap, + collectedTasks + ) + } + + // Spawn a task to render the segment data to a stream. + collectedTasks.push( + renderSegmentDataToStream( + fullPageDataBuffer, + clientModules, + serverConsumerManifest, + segmentPath, + segmentPathStr, + segmentBufferMap + ) + ) +} + +async function renderSegmentDataToStream( + fullPageDataBuffer: Buffer, + clientModules: ManifestNode, + serverConsumerManifest: any, + segmentPath: Array<[string, Segment]>, + segmentPathStr: string, + segmentBufferMap: Map +) { + // Create a new Flight response that contains data only for this segment. + try { + // Since all we're doing is decoding and re-encoding a cached prerender, if + // it takes longer than a microtask, it must because of hanging promises + // caused by dynamic data. Abort the stream at the end of the current task. + const abortController = new AbortController() + waitAtLeastOneReactRenderTask().then(() => abortController.abort()) + + const segmentStream = renderToReadableStream( + // SegmentPrefetch 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. + // @ts-expect-error + , + clientModules, + { + signal: abortController.signal, + onError() { + // Ignore any errors. These would have already been reported when + // we created the full page data. + }, + } + ) + const segmentBuffer = await streamToBuffer(segmentStream) + // Add the buffer to the result map. + if (segmentPathStr === '') { + segmentBufferMap.set('/', segmentBuffer) + } else { + segmentBufferMap.set(segmentPathStr, segmentBuffer) + } + } catch { + // If there are any errors, then we skip the segment. The effect is that + // a prefetch for this segment will 404. + } +} + +type SegmentPrefetch = { + rsc: React.ReactNode | null + loading: LoadingModuleData +} + +async function PickSegment({ + fullPageDataBuffer, + serverConsumerManifest, + segmentPath, +}: { + fullPageDataBuffer: Buffer + serverConsumerManifest: any + segmentPath: Array<[string, Segment]> +}): Promise { + // We're currently rendering a Flight response for a segment prefetch. + // Decode the Flight stream for the whole page, then pick out the data for the + // segment at the given path. This ends up happening once per segment. Not + // ideal, but we do it this way so that that we can transfer the side effects + // from the original Flight stream (e.g. Float preloads) onto the Flight + // stream for each segment's prefetch. + // + // This does mean that a prefetch for an individual segment will include the + // resources for the entire page it belongs to, but this is a reasonable + // trade-off for now. The main downside is a bit of extra bandwidth. + const replayConsoleLogs = true + const rscPayload: InitialRSCPayload = await createFromReadableStream( + streamFromBuffer(fullPageDataBuffer), + { + serverConsumerManifest, + replayConsoleLogs, + } + ) + + // FlightDataPaths is an unsound type, hence the additional checks. + const flightDataPaths = rscPayload.f + if (flightDataPaths.length !== 1 && flightDataPaths[0].length !== 3) { + console.error( + 'Internal Next.js error: InitialRSCPayload does not match the expected ' + + 'shape for a prerendered page during segment prefetch generation.' + ) + return null + } + + // This starts out as the data for the whole page. Use the segment path to + // find the data for the desired segment. + let seedData: CacheNodeSeedData = flightDataPaths[0][1] + for (const [parallelRouteKey] of segmentPath) { + // Normally when traversing a route tree we would compare the segments to + // confirm that they match (i.e. are representations of the same tree), + // but we don't bother to do that here because because the path was + // generated from the same data tree that we're currently traversing. + const children = seedData[2] + const child = children[parallelRouteKey] + if (!child) { + // No child found for this segment path. Exit. Again, this should be + // unreachable because the segment path was computed using the same + // source as the page data, but the type system doesn't know that. + return null + } else { + // Keep traversing down the segment path + seedData = child + } + } + + // We've reached the end of the segment path. seedData now represents the + // correct segment. + // + // In the future, this is where we can include additional metadata, like the + // stale time and cache tags. + const rsc = seedData[1] + const loading = seedData[3] + return { + rsc, + loading, + } +} + +// TODO: Consider updating or unifying this encoding logic for segments with +// createRouterCacheKey on the client, perhaps by including it as part of +// the FlightRouterState. Theoretically the client should never have to do its +// own encoding of segment keys; it can pass back whatever the server gave it. +function encodeChildSegmentAsFilesystemSafePathname( + parallelRouteKey: string, + segment: Segment +): string { + // Encode a child segment and its corresponding parallel route key to a + // filesystem-safe pathname. The format is internal-only and can be somewhat + // arbitrary as long as there are no collisions, because these will be used + // as filenames during build and in the incremental cache. They will also + // be sent by the client to request the corresponding segment, but they + // do not need to be decodable. The server will merely look for a matching + // file in the cache. + // + // For ease of debugging, the format looks roughly similar to the App Router + // convention for defining routes in the source, but again the exact format is + // not important as long as it's consistent between the client and server and + // meets the above requirements. + // + // TODO: If the segment did not read from params, then we can omit the + // params from the cache key. Need to track this during the prerender somehow. + let safeSegmentValue + if (typeof segment === 'string') { + safeSegmentValue = encodeParamValue(segment) + } else { + // Parameterized segments. + const [paramName, paramValue, paramType] = segment + let paramPrefix + switch (paramType) { + case 'c': + case 'ci': + paramPrefix = `[...${paramName}]` + break + case 'oc': + paramPrefix = `[[...${paramName}]]` + break + case 'd': + case 'di': + paramPrefix = `[${paramName}]` + break + default: + throw new Error('Unknown dynamic param type') + } + safeSegmentValue = `${paramPrefix}-${encodeParamValue(paramValue)}` + } + let result + if (parallelRouteKey === 'children') { + // Omit the parallel route key for children, since this is the most + // common case. Saves some bytes. + result = `${safeSegmentValue}` + } else { + result = `@${parallelRouteKey}/${safeSegmentValue}` + } + return result +} + +// Define a regex pattern to match the most common characters found in a route +// param. It excludes anything that might not be cross-platform filesystem +// compatible, like |. It does not need to be precise because the fallback is to +// just base64url-encode the whole parameter, which is fine; we just don't do it +// by default for compactness, and for easier debugging. +const simpleParamValueRegex = /^[a-zA-Z0-9\-_@]+$/ + +function encodeParamValue(segment: string): string { + if (segment === UNDERSCORE_NOT_FOUND_ROUTE) { + // TODO: FlightRouterState encodes Not Found routes as "/_not-found". But + // params typically don't include the leading slash. We should use a + // different encoding to avoid this special case. + return '_not-found' + } + if (simpleParamValueRegex.test(segment)) { + return segment + } + // If there are any unsafe characters, base64url-encode the entire segment. + // We also add a $ prefix so it doesn't collide with the simple case. + return '$' + Buffer.from(segment, 'utf-8').toString('base64url') +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index 9fe0b0f4a05f4..f7a1a0f693659 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -42,6 +42,7 @@ import { import { preloadStyle, preloadFont, preconnect } from './rsc/preloads' import { Postpone } from './rsc/postpone' import { taintObjectReference } from './rsc/taint' +export { collectSegmentData } from './collect-segment-data' // patchFetch makes use of APIs such as `React.unstable_postpone` which are only available // in the experimental channel of React, so export it from here so that it comes from the bundled runtime diff --git a/test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts b/test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts index b92dfcda1638f..3fef0d6623487 100644 --- a/test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts +++ b/test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts @@ -24,11 +24,9 @@ describe('per segment prefetching', () => { }) expect(response.status).toBe(200) const responseText = await response.text() - expect(responseText.trim()).toBe( - // The actual data is not yet generated, but this indicates that the - // request was handled correctly. - 'TODO (Per Segment Prefetching): Not yet implemented' - ) + // This is a basic check to ensure that the name of an expected field is + // somewhere in the Flight stream. + expect(responseText).toInclude('"rsc"') }) it('respond with 404 if the segment does not have prefetch data', async () => {