From 8fd9a6509a05756b403a9e4752e666e6802e9459 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 1 Nov 2024 00:34:46 -0400 Subject: [PATCH] Generate per-segment prefetch responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow up to #71113. This generates a prefetch response for route segments, so the client can request them individually, and reuse shared layouts between separate page prefetches. Since this is exclusive to PPR, segment prefetches are always static and do not reach the application layer. They are served directly from cache. (This part was implemented in the previous PR.) The prerendered segment data is generated (and revalidated) simultaneously with the prerender of the entire page, to ensure consistency between parent and child segments. Since we always rebuild the whole page, we can save an extra render of each segment by reusing the Flight response that was used to generate the initial HTML. We do this by decoding the page data using a server-side Flight consumer, picking out an individual segment's data, then re-encoding it as its own Flight response. We have to do this once per segment — not ideal, but for now it's the only way to make sure the side effects from the original Flight stream (e.g. Float preloads) are transferred to the per-segment streams. This PR does not yet include any updates to the client router. --- .../next/src/server/app-render/app-render.tsx | 153 +++++++-- .../app-render/collect-segment-data.tsx | 313 ++++++++++++++++++ .../next/src/server/app-render/entry-base.ts | 1 + .../simple/per-segment-prefetching.test.ts | 8 +- 4 files changed, 439 insertions(+), 36 deletions(-) create mode 100644 packages/next/src/server/app-render/collect-segment-data.tsx diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index d60aab21c72ef4..da04fbf237f5a5 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 @@ -2761,7 +2738,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) { @@ -3212,9 +3196,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, @@ -3337,6 +3328,12 @@ async function prerenderToStream( if (shouldGenerateStaticFlightData(workStore)) { metadata.flightData = flightData + metadata.segmentFlightData = await collectSegmentData( + RSCPayload, + flightData, + ComponentMod, + renderOpts + ) } /** @@ -3516,7 +3513,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({ @@ -3668,9 +3672,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 @@ -3797,3 +3808,83 @@ const getGlobalErrorStyles = async ( return globalErrorStyles } + +// The MaxListenersExceededWarning in Node.js is extremely sensitive; even just +// 11 AbortControllers in the same task will trigger the warning. So we create +// only one of these per task and store it in module scope, so it can be reused +// across multiple calls to collectSegmentData. +let nextTickAbortSignal: AbortSignal | null = null + +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, + } + + // Since all we're doing is decoding and re-encoding a cached prerender, if + // anything takes longer than a microtask, it must because of hanging promises + // caused by dynamic data. Abort the streams once the microtask queue + // is exhausted. + if (nextTickAbortSignal === null) { + const controller = new AbortController() + nextTickAbortSignal = controller.signal + process.nextTick(() => { + nextTickAbortSignal = null + controller.abort() + }) + } + + return await ComponentMod.collectSegmentData( + routeTree, + fullPageDataBuffer, + clientReferenceManifest.clientModules as ManifestNode, + serverConsumerManifest, + nextTickAbortSignal + ) +} 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 00000000000000..47a3d3b7a70f64 --- /dev/null +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -0,0 +1,313 @@ +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' + +// The MaxListenersExceededWarning in Node.js is extremely sensitive; even just +// 11 AbortControllers in the same task will trigger the warning. So we create +// only one of these per task and store it in module scope, so it can be reused +// across multiple calls to collectSegmentData. +let nextTickAbortSignal: AbortSignal | null = null + +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. + + // 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> = [] + + // Since all we're doing is decoding and re-encoding a cached prerender, if + // anything takes longer than a microtask, it must because of hanging promises + // caused by dynamic data. Abort the streams once the microtask queue + // is exhausted. + if (nextTickAbortSignal === null) { + const controller = new AbortController() + nextTickAbortSignal = controller.signal + process.nextTick(() => { + nextTickAbortSignal = null + controller.abort() + }) + } + + collectSegmentDataImpl( + routeTree, + fullPageDataBuffer, + clientModules, + serverConsumerManifest, + nextTickAbortSignal, + [], + '', + 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). + await Promise.all(collectedTasks) + + return segmentBufferMap +} + +function collectSegmentDataImpl( + route: FlightRouterState, + fullPageDataBuffer: Buffer, + clientModules: ManifestNode, + serverConsumerManifest: any, + abortSignal: AbortSignal, + 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, + abortSignal, + childSegmentPath, + childSegmentPathStr, + segmentBufferMap, + collectedTasks + ) + } + + // Spawn a task to render the segment data to a stream. + collectedTasks.push( + renderSegmentDataToStream( + fullPageDataBuffer, + clientModules, + serverConsumerManifest, + abortSignal, + segmentPath, + segmentPathStr, + segmentBufferMap + ) + ) +} + +async function renderSegmentDataToStream( + fullPageDataBuffer: Buffer, + clientModules: ManifestNode, + serverConsumerManifest: any, + abortSignal: AbortSignal, + segmentPath: Array<[string, Segment]>, + segmentPathStr: string, + segmentBufferMap: Map +) { + // Create a new Flight response that contains data only for this segment. + try { + const segmentStream = renderToReadableStream( + , + clientModules, + { + signal: abortSignal, + 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. + } +} + +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 base64-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, base64-encode the entire segment. + // Replace "+" with "-", and "/" with "_". We also add a $ prefix so it + // doesn't collide with the simple case. + return '$' + btoa(segment).replace(/\+/g, '-').replace(/\//g, '_') +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index 9fe0b0f4a05f43..f7a1a0f6936597 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 b92dfcda1638f0..3fef0d66234872 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 () => {