Support resuming a complete HTML prerender that has dynamic flight data (#60865)

followup to: https://github.com/vercel/next.js/pull/60645

### Background

When prerendering the determination of whether a prerender is fully
static or partially static should not be directly related to whether
there is a postponed state or not. When rendering RSC it is possible to
postpone because a dynamic API was used but then on the client (SSR) the
postpone is never encountered. This can happen when a server component
is passed to a client component and the client component conditionally
renders the server component.

Today if this happens the entire output would be considered static when
in fact the flight data encoded into the page and used for bootstrapping
the client router contains dynamic holes. Today this is blocked by an
error that incorrectly assumes that this case means the user caught the
postpone in the client layer but as shown above this may not be the
case.

### Implementation

A more capable model is to think of the outcome of a prerender as having
3 possible states
1. Dynamic HTML: The HTML produces by the prerender has dynamic holes.
we save the static prelude but expect to resume the render later to
complete the HTML. This means we will resume the RSC part of the render
as well
2. Dynamic Data: The HTML is completely static but the RSC data encoded
into the page is dynamic. We don't want to resume the render but we do
need to produce new inlined RSC data during a Request.
3. Static: The HTML is completely static and so is the RSC data encoded
into the page. We save the entire HTML output and there will be no
dynamic continuation when this route is visited.

Really 1 & 3 are the same as today (Partially static & Fully Static
respectively) but case 2 which today errors in a confusing way is now
supported.

In addition implementing the Dynamic Data case the old warning about
catching postpones is removed. The reason we don't want this is that
catching postpones is potentially a valid way to do optimistic UI. We
probably want a first-party API for it at some point (and maybe we'll
add the warning back in once we do) but imagine you do something dynamic
like look up a user but during prerender you want to render as if the
user is logged out. you could call `getUser()` in a try catch and render
fallback UI if it throws. In this case we'd detect a dynamic API was
used but we wouldn't have a corresponding postpone state which would put
us in the Dynamic Data case (2).

Another item to note is that we can produce a fully static result even
if there is a postponed state because users may call postpone themselves
even if they are not calling dynamic APIs like headers or cookies. When
this happens we don't want to statically capture a page with postponed
boundaries in it. Instead we immediately resume the render and abort it
with a postponed abort signal. This will cause the boundaries to
immediately enter client render mode which should speed up recovery on
the client.

#### Technical Note

Another note about the implementation is that you'll see that regardless
of which case we are in, if there is a postponed state but we consider
the page to be Dynamic Data meaning we want to serialize all the HTML
and NOT do a resume in the dynamic continuation then we immediately
resume the render with and already aborted AbortSignal. The purpose here
is to mark any boundaries which have dynamic holes as being
client-rendered.

As a general rule if the render produces a postponed state we must do
one of the following
1. save the postponed state and ensure there is a dynamic continuation
that calls resume
2. immediately resume the render and save the concatenated output and
ensure the dynamic continuation does NOT call resume.

or said another way, every postponed state must be resumed (even if it
didn't come from Next's dynamic APIs)

#### Perf considerations

This PR modifies a few key areas to improve perf.

Reduces quantity of *Stream instances where possible as these add
significant overhead
Reduces extra closures to lower allocations and keep functions in
monomorphic form where possible


Closes NEXT-2164
This commit is contained in:
Josh Story 2024-02-12 15:59:13 -08:00 committed by GitHub
parent 1e1f77426d
commit ff7c5c2ba3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1098 additions and 557 deletions

View file

@ -3,13 +3,10 @@ import type { IncrementalCache } from '../../server/lib/incremental-cache'
import type { DynamicServerError } from './hooks-server-context'
import type { FetchMetrics } from '../../server/base-http'
import type { Revalidate } from '../../server/lib/revalidate'
import type { PrerenderState } from '../../server/app-render/dynamic-rendering'
import { createAsyncLocalStorage } from './async-local-storage'
type PrerenderState = {
hasDynamic: boolean
}
export interface StaticGenerationStore {
readonly isStaticGeneration: boolean
readonly pagePath?: string

View file

@ -33,7 +33,6 @@ import { exportPages } from './routes/pages'
import { getParams } from './helpers/get-params'
import { createIncrementalCache } from './helpers/create-incremental-cache'
import { isPostpone } from '../server/lib/router-utils/is-postpone'
import { isMissingPostponeDataError } from '../server/app-render/is-missing-postpone-error'
import { isDynamicUsageError } from './helpers/is-dynamic-usage-error'
import { isBailoutToCSRError } from '../shared/lib/lazy-dynamic/bailout-to-csr'
import {
@ -320,14 +319,11 @@ async function exportPageImpl(
fileWriter
)
} catch (err) {
// if this is a postpone error, it's logged elsewhere, so no need to log it again here
if (!isMissingPostponeDataError(err)) {
console.error(
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n`
)
if (!isBailoutToCSRError(err)) {
console.error(isError(err) && err.stack ? err.stack : err)
}
console.error(
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n`
)
if (!isBailoutToCSRError(err)) {
console.error(isError(err) && err.stack ? err.stack : err)
}
return { error: true }

View file

@ -32,3 +32,12 @@ export const scheduleImmediate = <T = void>(cb: ScheduledFn<T>): void => {
setImmediate(cb)
}
}
/**
* returns a promise than resolves in a future task. There is no guarantee that the task it resolves in
* will be the next task but if you await it you can at least be sure that the current task is over and
* most usefully that the entire microtask queue of the current task has been emptied.
*/
export function atLeastOneTask() {
return new Promise<void>((resolve) => scheduleImmediate(resolve))
}

View file

@ -19,18 +19,19 @@ import type { Revalidate } from '../lib/revalidate'
import React from 'react'
import { createReactServerRenderer } from './create-server-components-renderer'
import RenderResult, {
type AppPageRenderResultMetadata,
type RenderResultOptions,
type RenderResultResponse,
} from '../render-result'
import {
chainStreams,
renderToInitialFizzStream,
continueFizzStream,
cloneTransformStream,
type ContinueStreamOptions,
continuePostponedFizzStream,
continueDynamicPrerender,
continueStaticPrerender,
continueDynamicHTMLResume,
continueDynamicDataResume,
} from '../stream-utils/node-web-streams-helper'
import { canSegmentBeOverridden } from '../../client/components/match-segments'
import { stripInternalQueries } from '../internal-utils'
@ -78,15 +79,27 @@ import { walkTreeWithFlightRouterState } from './walk-tree-with-flight-router-st
import { createComponentTree } from './create-component-tree'
import { getAssetQueryString } from './get-asset-query-string'
import { setReferenceManifestsSingleton } from './action-encryption-utils'
import { createStaticRenderer } from './static/static-renderer'
import { MissingPostponeDataError } from './is-missing-postpone-error'
import { DetachedPromise } from '../../lib/detached-promise'
import {
createStaticRenderer,
getDynamicDataPostponedState,
getDynamicHTMLPostponedState,
} from './static/static-renderer'
import { isDynamicServerError } from '../../client/components/hooks-server-context'
import { useFlightResponse } from './use-flight-response'
import { isStaticGenBailoutError } from '../../client/components/static-generation-bailout'
import {
useFlightStream,
createInlinedDataReadableStream,
flightRenderComplete,
} from './use-flight-response'
import {
StaticGenBailoutError,
isStaticGenBailoutError,
} from '../../client/components/static-generation-bailout'
import { isInterceptionRouteAppPath } from '../future/helpers/interception-routes'
import { getStackWithoutErrorMessage } from '../../lib/format-server-error'
import { isNavigationSignalError } from '../../export/helpers/is-navigation-signal-error'
import {
usedDynamicAPIs,
createPostponedAbortSignal,
} from './dynamic-rendering'
export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
@ -365,17 +378,10 @@ function createFlightDataResolver(ctx: AppRenderContext) {
type ReactServerAppProps = {
tree: LoaderTree
ctx: AppRenderContext
preinitScripts: () => void
asNotFound: boolean
}
// This is the root component that runs in the RSC context
async function ReactServerApp({
tree,
ctx,
preinitScripts,
asNotFound,
}: ReactServerAppProps) {
preinitScripts()
async function ReactServerApp({ tree, ctx, asNotFound }: ReactServerAppProps) {
// Create full component tree from root to leaf.
const injectedCSS = new Set<string>()
const injectedJS = new Set<string>()
@ -455,14 +461,12 @@ async function ReactServerApp({
type ReactServerErrorProps = {
tree: LoaderTree
ctx: AppRenderContext
preinitScripts: () => void
errorType: 'not-found' | 'redirect' | undefined
}
// This is the root component that runs in the RSC context
async function ReactServerError({
tree,
ctx,
preinitScripts,
errorType,
}: ReactServerErrorProps) {
const {
@ -479,7 +483,6 @@ async function ReactServerError({
res,
} = ctx
preinitScripts()
const [MetadataTree] = createMetadataComponents({
tree,
pathname: urlPathname,
@ -532,31 +535,33 @@ async function ReactServerError({
}
// This component must run in an SSR context. It will render the RSC root component
function ReactServerEntrypoint({
renderReactServer,
inlinedDataTransformStream,
function ReactServerEntrypoint<T>({
reactServerStream,
preinitScripts,
clientReferenceManifest,
formState,
nonce,
}: {
renderReactServer: () => ReadableStream<Uint8Array>
inlinedDataTransformStream: TransformStream<Uint8Array, Uint8Array>
reactServerStream: BinaryStreamOf<T>
preinitScripts: () => void
clientReferenceManifest: NonNullable<RenderOpts['clientReferenceManifest']>
formState: null | any
nonce?: string
}) {
const writable = inlinedDataTransformStream.writable
const reactServerRequestStream = renderReactServer()
const reactServerResponse = useFlightResponse(
writable,
reactServerRequestStream,
}): T {
preinitScripts()
const response = useFlightStream(
reactServerStream,
clientReferenceManifest,
formState,
nonce
)
return React.use(reactServerResponse)
return React.use(response)
}
// We use a trick with TS Generics to branch streams with a type so we can
// consume the parsed value of a Readable Stream if it was constructed with a
// certain object shape. The generic type is not used directly in the type so it
// requires a disabling of the eslint rule disallowing unused vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type BinaryStreamOf<T> = ReadableStream<Uint8Array>
async function renderToHTMLOrFlightImpl(
req: IncomingMessage,
res: ServerResponse,
@ -668,21 +673,6 @@ async function renderToHTMLOrFlightImpl(
silenceLogger: silenceStaticGenerationErrors,
})
/**
* This postpone handler will be used to help us discriminate between a set of cases
* 1. SSR or RSC postpone that was caught and not rethrown
* 2. SSR postpone handled by React
* 3. RSC postpone handled by React
*
* The previous technique for tracking postpones could not tell between cases 1 and 3
* however we only want to warn on the first case
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let serverComponentsDidPostpone = false
const serverComponentsPostponeHandler = (_reason: unknown) => {
serverComponentsDidPostpone = true
}
ComponentMod.patchFetch()
/**
@ -710,8 +700,6 @@ async function renderToHTMLOrFlightImpl(
)
}
const { urlPathname } = staticGenerationStore
staticGenerationStore.fetchMetrics = []
metadata.fetchMetrics = staticGenerationStore.fetchMetrics
@ -791,8 +779,6 @@ async function renderToHTMLOrFlightImpl(
return generateFlight(ctx)
}
const hasPostponed = typeof renderOpts.postponed === 'string'
// Create the resolver that can get the flight payload when it's ready or
// throw the error if it occurred. If we are not generating static HTML, we
// don't need to generate the flight payload because it's a dynamic request
@ -833,11 +819,6 @@ async function renderToHTMLOrFlightImpl(
getTracer().getRootSpanAttributes()?.set('next.route', pagePath)
// Create a promise that will help us signal when the headers have been
// written to the metadata for static generation as they aren't written to the
// response directly.
const onHeadersFinished = new DetachedPromise<void>()
const renderToStream = getTracer().wrap(
AppRenderSpan.getBodyResult,
{
@ -877,26 +858,19 @@ async function renderToHTMLOrFlightImpl(
nonce
)
// This will when called actually render the RSC layer. During an SSR pass it will
// typically get passed to a Entrypoint component which calls initiates it during the
// the SSR render however there are some cases where this
const serverComponentsRenderer = createReactServerRenderer(
<ReactServerApp
tree={tree}
ctx={ctx}
preinitScripts={preinitScripts}
asNotFound={asNotFound}
/>,
ComponentMod,
clientReferenceManifest,
serverComponentsErrorHandler,
serverComponentsPostponeHandler
// We kick off the Flight Request (render) here. It is ok to initiate the render in an arbitrary
// place however it is critical that we only construct the Flight Response inside the SSR
// render so that directives like preloads are correctly piped through
const serverStream = ComponentMod.renderToReadableStream(
<ReactServerApp tree={tree} ctx={ctx} asNotFound={asNotFound} />,
clientReferenceManifest.clientModules,
{
onError: serverComponentsErrorHandler,
}
)
const renderInlinedDataTransformStream = new TransformStream<
Uint8Array,
Uint8Array
>()
// We are going to consume this render both for SSR and for inlining the flight data
let [renderStream, dataStream] = serverStream.tee()
const children = (
<HeadManagerContext.Provider
@ -907,20 +881,44 @@ async function renderToHTMLOrFlightImpl(
>
<ServerInsertedHTMLProvider>
<ReactServerEntrypoint
renderReactServer={serverComponentsRenderer}
inlinedDataTransformStream={renderInlinedDataTransformStream}
reactServerStream={renderStream}
preinitScripts={preinitScripts}
clientReferenceManifest={clientReferenceManifest}
formState={formState}
nonce={nonce}
/>
</ServerInsertedHTMLProvider>
</HeadManagerContext.Provider>
)
const isResume = !!renderOpts.postponed
const onHeaders = staticGenerationStore.prerenderState
? // During prerender we write headers to metadata
(headers: Headers) => {
headers.forEach((value, key) => {
metadata.headers ??= {}
metadata.headers[key] = value
})
}
: isStaticGeneration || isResume
? // During static generation and during resumes we don't
// ask React to emit headers. For Resume this is just not supported
// For static generation we know there will be an entire HTML document
// output and so moving from tag to header for preloading can only
// server to alter preloading priorities in unwanted ways
undefined
: // During dynamic renders that are not resumes we write
// early headers to the response
(headers: Headers) => {
headers.forEach((value, key) => {
res.appendHeader(key, value)
})
}
const getServerInsertedHTML = makeGetServerInsertedHTML({
polyfills,
renderServerInsertedHTML,
hasPostponed,
serverCapturedErrors: allCapturedErrors,
})
const renderer = createStaticRenderer({
@ -928,29 +926,13 @@ async function renderToHTMLOrFlightImpl(
isStaticGeneration,
// If provided, the postpone state should be parsed as JSON so it can be
// provided to React.
postponed: renderOpts.postponed
? JSON.parse(renderOpts.postponed)
: null,
postponed:
typeof renderOpts.postponed === 'string'
? JSON.parse(renderOpts.postponed)
: null,
streamOptions: {
onError: htmlRendererErrorHandler,
onHeaders: (headers: Headers) => {
// If this is during static generation, we shouldn't write to the
// headers object directly, instead we should add to the render
// result.
if (isStaticGeneration) {
headers.forEach((value, key) => {
metadata.headers ??= {}
metadata.headers[key] = value
})
// Resolve the promise to continue the stream.
onHeadersFinished.resolve()
} else {
headers.forEach((value, key) => {
res.appendHeader(key, value)
})
}
},
onHeaders,
maxHeadersLength: 600,
nonce,
bootstrapScripts: [bootstrapScript],
@ -959,44 +941,187 @@ async function renderToHTMLOrFlightImpl(
})
try {
let { stream, postponed } = await renderer.render(children)
let { stream, postponed, resumed } = await renderer.render(children)
// If the stream was postponed, we need to add the result to the
// metadata so that it can be resumed later.
if (postponed) {
// If our render did not produce a postponed state but we did postpone
// during the RSC render we need to still treat this as a postpone
metadata.postponed = JSON.stringify(postponed)
const prerenderState = staticGenerationStore.prerenderState
if (prerenderState) {
/**
* When prerendering there are three outcomes to consider
*
* Dynamic HTML: The prerender has dynamic holes (caused by using Next.js Dynamic Rendering APIs)
* We will need to resume this result when requests are handled and we don't include
* any server inserted HTML or inlined flight data in the static HTML
*
* Dynamic Data: The prerender has no dynamic holes but dynamic APIs were used. We will not
* resume this render when requests are handled but we will generate new inlined
* flight data since it is dynamic and differences may end up reconciling on the client
*
* Static: The prerender has no dynamic holes and no dynamic APIs were used. We statically encode
* all server inserted HTML and flight data
*/
// We don't need to "continue" this stream now as it's continued when
// we resume the stream.
return { stream }
}
// First we check if we have any dynamic holes in our HTML prerender
if (usedDynamicAPIs(prerenderState)) {
if (postponed != null) {
// This is the Dynamic HTML case.
metadata.postponed = JSON.stringify(
getDynamicHTMLPostponedState(postponed)
)
} else {
// This is the Dynamic Data case
metadata.postponed = JSON.stringify(
getDynamicDataPostponedState()
)
}
// Regardless of whether this is the Dynamic HTML or Dynamic Data case we need to ensure we include
// server inserted html in the static response because the html that is part of the prerender may depend on it
// It is possible in the set of stream transforms for Dynamic HTML vs Dynamic Data may differ but currently both states
// require the same set so we unify the code path here
return {
stream: await continueDynamicPrerender(stream, {
getServerInsertedHTML,
}),
}
} else {
// We may still be rendering the RSC stream even though the HTML is finished.
// We wait for the RSC stream to complete and check again if dynamic was used
const [original, flightSpy] = dataStream.tee()
dataStream = original
const options: ContinueStreamOptions = {
inlinedDataStream: renderInlinedDataTransformStream.readable,
isStaticGeneration: isStaticGeneration || generateStaticHTML,
getServerInsertedHTML: () => getServerInsertedHTML(allCapturedErrors),
serverInsertedHTMLToHead: !renderOpts.postponed,
// If this render generated a postponed state or this is a resume
// render, we don't want to validate the root layout as it's already
// partially rendered.
validateRootLayout:
!postponed && !renderOpts.postponed
? validateRootLayout
: undefined,
// App Render doesn't need to inject any additional suffixes.
suffix: undefined,
}
await flightRenderComplete(flightSpy)
if (renderOpts.postponed) {
stream = await continuePostponedFizzStream(stream, options)
if (usedDynamicAPIs(prerenderState)) {
// This is the same logic above just repeated after ensuring the RSC stream itself has completed
if (postponed != null) {
// This is the Dynamic HTML case.
metadata.postponed = JSON.stringify(
getDynamicHTMLPostponedState(postponed)
)
} else {
// This is the Dynamic Data case
metadata.postponed = JSON.stringify(
getDynamicDataPostponedState()
)
}
// Regardless of whether this is the Dynamic HTML or Dynamic Data case we need to ensure we include
// server inserted html in the static response because the html that is part of the prerender may depend on it
// It is possible in the set of stream transforms for Dynamic HTML vs Dynamic Data may differ but currently both states
// require the same set so we unify the code path here
return {
stream: await continueDynamicPrerender(stream, {
getServerInsertedHTML,
}),
}
} else {
// This is the Static case
// We still have not used any dynamic APIs. At this point we can produce an entirely static prerender response
let renderedHTMLStream = stream
if (staticGenerationStore.forceDynamic) {
throw new StaticGenBailoutError(
'Invariant: a Page with `dynamic = "force-dynamic"` did not trigger the dynamic pathway. This is a bug in Next.js'
)
}
if (postponed != null) {
// We postponed but nothing dynamic was used. We resume the render now and immediately abort it
// so we can set all the postponed boundaries to client render mode before we store the HTML response
const resumeRenderer = createStaticRenderer({
ppr: true,
isStaticGeneration: false,
postponed: getDynamicHTMLPostponedState(postponed),
streamOptions: {
signal: createPostponedAbortSignal(
'static prerender resume'
),
onError: htmlRendererErrorHandler,
nonce,
},
})
// We don't actually want to render anything so we just pass a stream
// that never resolves. The resume call is going to abort immediately anyway
const foreverStream = new ReadableStream<Uint8Array>()
const resumeChildren = (
<HeadManagerContext.Provider
value={{
appDir: true,
nonce,
}}
>
<ServerInsertedHTMLProvider>
<ReactServerEntrypoint
reactServerStream={foreverStream}
preinitScripts={() => {}}
clientReferenceManifest={clientReferenceManifest}
nonce={nonce}
/>
</ServerInsertedHTMLProvider>
</HeadManagerContext.Provider>
)
const { stream: resumeStream } = await resumeRenderer.render(
resumeChildren
)
// First we write everything from the prerender, then we write everything from the aborted resume render
renderedHTMLStream = chainStreams(stream, resumeStream)
}
return {
stream: await continueStaticPrerender(renderedHTMLStream, {
inlinedDataStream: createInlinedDataReadableStream(
dataStream,
nonce,
formState
),
getServerInsertedHTML,
}),
}
}
}
} else if (renderOpts.postponed) {
// This is a continuation of either an Incomplete or Dynamic Data Prerender.
const inlinedDataStream = createInlinedDataReadableStream(
dataStream,
nonce,
formState
)
if (resumed) {
// We have new HTML to stream and we also need to include server inserted HTML
return {
stream: await continueDynamicHTMLResume(stream, {
inlinedDataStream,
getServerInsertedHTML,
}),
}
} else {
// We are continuing a Dynamic Data Prerender and simply need to append new inlined flight data
return {
stream: await continueDynamicDataResume(stream, {
inlinedDataStream,
}),
}
}
} else {
stream = await continueFizzStream(stream, options)
// This may be a static render or a dynamic render
// @TODO factor this further to make the render types more clearly defined and remove
// the deluge of optional params that passed to configure the various behaviors
return {
stream: await continueFizzStream(stream, {
inlinedDataStream: createInlinedDataReadableStream(
dataStream,
nonce,
formState
),
isStaticGeneration: isStaticGeneration || generateStaticHTML,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
validateRootLayout,
}),
}
}
return { stream }
} catch (err: any) {
} catch (err) {
if (
isStaticGenBailoutError(err) ||
(typeof err === 'object' &&
@ -1021,8 +1146,6 @@ async function renderToHTMLOrFlightImpl(
// a suspense boundary.
const shouldBailoutToCSR = isBailoutToCSRError(err)
if (shouldBailoutToCSR) {
console.log()
if (renderOpts.experimental.missingSuspenseWithCSRBailout) {
const stack = getStackWithoutErrorMessage(err)
error(
@ -1080,23 +1203,12 @@ async function renderToHTMLOrFlightImpl(
nonce
)
const errorServerComponentsRenderer = createReactServerRenderer(
<ReactServerError
tree={tree}
ctx={ctx}
preinitScripts={errorPreinitScripts}
errorType={errorType}
/>,
ComponentMod,
clientReferenceManifest,
serverComponentsErrorHandler,
serverComponentsPostponeHandler
)
// Preserve the existing RSC inline chunks from the page rendering.
// To avoid the same stream being operated twice, clone the origin stream for error rendering.
const errorInlinedDataTransformStream = cloneTransformStream(
renderInlinedDataTransformStream
const errorServerStream = ComponentMod.renderToReadableStream(
<ReactServerError tree={tree} ctx={ctx} errorType={errorType} />,
clientReferenceManifest.clientModules,
{
onError: serverComponentsErrorHandler,
}
)
try {
@ -1104,10 +1216,9 @@ async function renderToHTMLOrFlightImpl(
ReactDOMServer: require('react-dom/server.edge'),
element: (
<ReactServerEntrypoint
renderReactServer={errorServerComponentsRenderer}
inlinedDataTransformStream={errorInlinedDataTransformStream}
reactServerStream={errorServerStream}
preinitScripts={errorPreinitScripts}
clientReferenceManifest={clientReferenceManifest}
formState={formState}
nonce={nonce}
/>
),
@ -1124,12 +1235,22 @@ async function renderToHTMLOrFlightImpl(
// the response in the caller.
err,
stream: await continueFizzStream(fizzStream, {
inlinedDataStream: errorInlinedDataTransformStream.readable,
inlinedDataStream: createInlinedDataReadableStream(
// This is intentionally using the readable datastream from the
// main render rather than the flight data from the error page
// render
dataStream,
nonce,
formState
),
isStaticGeneration,
getServerInsertedHTML: () => getServerInsertedHTML([]),
getServerInsertedHTML: makeGetServerInsertedHTML({
polyfills,
renderServerInsertedHTML,
serverCapturedErrors: [],
}),
serverInsertedHTMLToHead: true,
validateRootLayout,
suffix: undefined,
}),
}
} catch (finalErr: any) {
@ -1216,62 +1337,9 @@ async function renderToHTMLOrFlightImpl(
// sending it back to be sent to the client.
response.stream = await result.toUnchunkedString(true)
// Timeout after 1.5 seconds for the headers to write. If it takes
// longer than this it's more likely that the stream has stalled and
// there is a React bug. The headers will then be updated in the render
// result below when the metadata is re-added to the new render result.
const onTimeout = new DetachedPromise<never>()
const timeout = setTimeout(() => {
onTimeout.reject(
new Error(
'Timeout waiting for headers to be emitted, this is a bug in Next.js'
)
)
}, 1500)
// Race against the timeout and the headers being written.
await Promise.race([onHeadersFinished.promise, onTimeout.promise])
// It got here, which means it did not reject, so clear the timeout to avoid
// it from rejecting again (which is a no-op anyways).
clearTimeout(timeout)
const buildFailingError =
digestErrorsMap.size > 0 ? digestErrorsMap.values().next().value : null
// If PPR is enabled and the postpone was triggered but lacks the postponed
// state information then we should error out unless the error was a
// navigation signal error or a client-side rendering bailout error.
if (
staticGenerationStore.prerenderState &&
staticGenerationStore.prerenderState.hasDynamic &&
!metadata.postponed &&
(!response.err ||
(!isBailoutToCSRError(response.err) &&
!isNavigationSignalError(response.err)))
) {
// a call to postpone was made but was caught and not detected by Next.js. We should fail the build immediately
// as we won't be able to generate the static part
warn('')
error(
`Prerendering ${urlPathname} needs to partially bail out because something dynamic was used. ` +
`React throws a special object to indicate where we need to bail out but it was caught ` +
`by a try/catch or a Promise was not awaited. These special objects should not be caught ` +
`by your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error`
)
if (digestErrorsMap.size > 0) {
warn(
'The following error was thrown during build, and may help identify the source of the issue:'
)
error(buildFailingError)
}
throw new MissingPostponeDataError(
`An unexpected error occurred while prerendering ${urlPathname}. Please check the logs above for more details.`
)
}
if (!flightDataResolver) {
throw new Error(
'Invariant: Flight data resolver is missing when generating static HTML'

View file

@ -531,7 +531,11 @@ async function createComponentTreeInternal({
seedData: [
actualSegment,
parallelRouteCacheNodeSeedData,
<Postpone reason='dynamic = "force-dynamic" was used' />,
<Postpone
prerenderState={staticGenerationStore.prerenderState}
reason='dynamic = "force-dynamic" was used'
pathname={staticGenerationStore.urlPathname}
/>,
],
styles: layerAssets,
}

View file

@ -1,32 +0,0 @@
import type { RenderOpts } from './types'
import type { AppPageModule } from '../future/route-modules/app-page/module'
import type { createErrorHandler } from './create-error-handler'
/**
* Create a component that renders the Flight stream.
* This is only used for renderToHTML, the Flight response does not need additional wrappers.
*/
export function createReactServerRenderer(
children: React.ReactNode,
ComponentMod: AppPageModule,
clientReferenceManifest: NonNullable<RenderOpts['clientReferenceManifest']>,
onError: ReturnType<typeof createErrorHandler>,
onPostpone: (reason: unknown) => void
): () => ReadableStream<Uint8Array> {
let flightStream: ReadableStream<Uint8Array>
return function renderToReactServerStream() {
if (flightStream) {
return flightStream
} else {
flightStream = ComponentMod.renderToReadableStream(
children,
clientReferenceManifest.clientModules,
{
onError,
onPostpone,
}
)
return flightStream
}
}
}

View file

@ -30,6 +30,15 @@ import { getPathname } from '../../lib/url'
const hasPostpone = typeof React.unstable_postpone === 'function'
// Stores dynamic reasons used during a render
export type PrerenderState = { hasDynamic: boolean }
export function createPrerenderState(): PrerenderState {
return {
hasDynamic: false,
}
}
/**
* This function communicates that the current scope should be treated as dynamic.
*
@ -54,12 +63,10 @@ export function markCurrentScopeAsDynamic(
// We are in a prerender (PPR enabled, during build)
store.prerenderState
) {
assertPostpone()
// We track that we had a dynamic scope that postponed.
// This will be used by the renderer to decide whether
// the prerender requires a resume
store.prerenderState.hasDynamic = true
React.unstable_postpone(createPostponeReason(expression, pathname))
postponeWithTracking(store.prerenderState, expression, pathname)
} else {
store.revalidate = 0
@ -102,12 +109,10 @@ export function trackDynamicDataAccessed(
// We are in a prerender (PPR enabled, during build)
store.prerenderState
) {
assertPostpone()
// We track that we had a dynamic scope that postponed.
// This will be used by the renderer to decide whether
// the prerender requires a resume
store.prerenderState.hasDynamic = true
React.unstable_postpone(createPostponeReason(expression, pathname))
postponeWithTracking(store.prerenderState, expression, pathname)
} else {
store.revalidate = 0
@ -129,10 +134,15 @@ export function trackDynamicDataAccessed(
*/
type PostponeProps = {
reason: string
prerenderState: PrerenderState
pathname: string
}
export function Postpone({ reason }: PostponeProps): never {
assertPostpone()
return React.unstable_postpone(reason)
export function Postpone({
reason,
prerenderState,
pathname,
}: PostponeProps): never {
postponeWithTracking(prerenderState, reason, pathname)
}
// @TODO refactor patch-fetch and this function to better model dynamic semantics. Currently this implementation
@ -144,19 +154,26 @@ export function trackDynamicFetch(
expression: string
) {
if (store.prerenderState) {
assertPostpone()
store.prerenderState.hasDynamic = true
React.unstable_postpone(createPostponeReason(expression, store.urlPathname))
postponeWithTracking(store.prerenderState, expression, store.urlPathname)
}
}
function createPostponeReason(expression: string, urlPathname: string) {
const pathname = getPathname(urlPathname) // remove queries such like `_rsc` for flight
return (
function postponeWithTracking(
prerenderState: PrerenderState,
expression: string,
pathname: string
): never {
assertPostpone()
const reason =
`Route ${pathname} needs to bail out of prerendering at this point because it used ${expression}. ` +
`React throws this special object to indicate where. It should not be caught by ` +
`your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error`
)
prerenderState.hasDynamic = true
React.unstable_postpone(reason)
}
export function usedDynamicAPIs(prerenderState: PrerenderState): boolean {
return prerenderState.hasDynamic === true
}
function assertPostpone() {
@ -166,3 +183,19 @@ function assertPostpone() {
)
}
}
/**
* This is a bit of a hack to allow us to abort a render using a Postpone instance instead of an Error which changes React's
* abort semantics slightly.
*/
export function createPostponedAbortSignal(reason: string): AbortSignal {
assertPostpone()
const controller = new AbortController()
// We get our hands on a postpone instance by calling postpone and catching the throw
try {
React.unstable_postpone(reason)
} catch (x: unknown) {
controller.abort(x)
}
return controller.signal
}

View file

@ -1,12 +0,0 @@
export const MISSING_POSTPONE_DATA_ERROR = 'MISSING_POSTPONE_DATA_ERROR'
export class MissingPostponeDataError extends Error {
digest: typeof MISSING_POSTPONE_DATA_ERROR = MISSING_POSTPONE_DATA_ERROR
constructor(type: string) {
super(`Missing Postpone Data Error: ${type}`)
}
}
export const isMissingPostponeDataError = (err: any) =>
err.digest === MISSING_POSTPONE_DATA_ERROR

View file

@ -12,17 +12,16 @@ import { RedirectStatusCode } from '../../client/components/redirect-status-code
export function makeGetServerInsertedHTML({
polyfills,
renderServerInsertedHTML,
hasPostponed,
serverCapturedErrors,
}: {
polyfills: JSX.IntrinsicElements['script'][]
renderServerInsertedHTML: () => React.ReactNode
hasPostponed: boolean
serverCapturedErrors: Error[]
}) {
let flushedErrorMetaTagsUntilIndex = 0
// If the render had postponed, then we have already flushed the polyfills.
let polyfillsFlushed = hasPostponed
let hasUnflushedPolyfills = polyfills.length !== 0
return async function getServerInsertedHTML(serverCapturedErrors: Error[]) {
return async function getServerInsertedHTML() {
// Loop through all the errors that have been captured but not yet
// flushed.
const errorMetaTags = []
@ -56,18 +55,19 @@ export function makeGetServerInsertedHTML({
const stream = await renderToReadableStream(
<>
{/* Insert the polyfills if they haven't been flushed yet. */}
{!polyfillsFlushed &&
polyfills?.map((polyfill) => {
return <script key={polyfill.src} {...polyfill} />
})}
{
/* Insert the polyfills if they haven't been flushed yet. */
hasUnflushedPolyfills &&
polyfills.map((polyfill) => {
return <script key={polyfill.src} {...polyfill} />
})
}
{renderServerInsertedHTML()}
{errorMetaTags}
</>
)
// Mark polyfills as flushed so they don't get flushed again.
if (!polyfillsFlushed) polyfillsFlushed = true
hasUnflushedPolyfills = false
// Wait for the stream to be ready.
await stream.allReady

View file

@ -7,6 +7,7 @@ import type { Options as PrerenderOptions } from 'react-dom/static.edge'
type RenderResult = {
stream: ReadableStream<Uint8Array>
postponed?: object | null
resumed?: boolean
}
export interface Renderer {
@ -40,7 +41,7 @@ class StaticResumeRenderer implements Renderer {
public async render(children: JSX.Element) {
const stream = await this.resume(children, this.postponed, this.options)
return { stream }
return { stream, resumed: true }
}
}
@ -56,6 +57,20 @@ export class ServerRenderer implements Renderer {
}
}
export class VoidRenderer implements Renderer {
public async render(_children: JSX.Element): Promise<RenderResult> {
return {
stream: new ReadableStream({
start(controller) {
// Close the stream immediately
controller.close()
},
}),
resumed: false,
}
}
}
/**
* This represents all the possible configuration options for each of the
* available renderers. We pick the specific options we need for each renderer
@ -66,13 +81,34 @@ export class ServerRenderer implements Renderer {
type StreamOptions = Pick<
ResumeOptions & RenderToReadableStreamOptions & PrerenderOptions,
| 'onError'
| 'onPostpone'
| 'onHeaders'
| 'maxHeadersLength'
| 'nonce'
| 'bootstrapScripts'
| 'formState'
| 'signal'
>
export const DYNAMIC_DATA = 1 as const
export const DYNAMIC_HTML = 2 as const
type DynamicDataPostponedState = typeof DYNAMIC_DATA
type DynamicHTMLPostponedState = [typeof DYNAMIC_HTML, object]
export type PostponedState =
| DynamicDataPostponedState
| DynamicHTMLPostponedState
export function getDynamicHTMLPostponedState(
data: object
): DynamicHTMLPostponedState {
return [DYNAMIC_HTML, data]
}
export function getDynamicDataPostponedState(): DynamicDataPostponedState {
return DYNAMIC_DATA
}
type Options = {
/**
* Whether or not PPR is enabled. This is used to determine which renderer to
@ -90,7 +126,7 @@ type Options = {
* The postponed state for the render. This is only used when resuming a
* prerender that has postponed.
*/
postponed: object | null
postponed: null | PostponedState
/**
* The options for any of the renderers. This is a union of all the possible
@ -106,7 +142,9 @@ export function createStaticRenderer({
isStaticGeneration,
postponed,
streamOptions: {
signal,
onError,
onPostpone,
onHeaders,
maxHeadersLength,
nonce,
@ -116,24 +154,56 @@ export function createStaticRenderer({
}: Options): Renderer {
if (ppr) {
if (isStaticGeneration) {
// This is a Prerender
return new StaticRenderer({
signal,
onError,
onPostpone,
// We want to capture headers because we may not end up with a shell
// and being able to send headers is the next best thing
onHeaders,
maxHeadersLength,
bootstrapScripts,
})
}
if (postponed) {
return new StaticResumeRenderer(postponed, {
onError,
nonce,
})
} else {
// This is a Resume
if (postponed === DYNAMIC_DATA) {
// The HTML was complete, we don't actually need to render anything
return new VoidRenderer()
} else if (postponed) {
const reactPostponedState = postponed[1]
// The HTML had dynamic holes and we need to resume it
return new StaticResumeRenderer(reactPostponedState, {
signal,
onError,
onPostpone,
nonce,
})
}
}
}
if (isStaticGeneration) {
// This is a static render (without PPR)
return new ServerRenderer({
signal,
onError,
// We don't pass onHeaders. In static builds we will either have no output
// or the entire page. In either case preload headers aren't necessary and could
// alter the prioritiy of relative loading of resources so we opt to keep them
// as tags exclusively.
nonce,
bootstrapScripts,
formState,
})
}
// This is a dynamic render (without PPR)
return new ServerRenderer({
signal,
onError,
// Static renders are streamed in realtime so sending headers early is
// generally good because it will likely go out before the shell is ready.
onHeaders,
maxHeadersLength,
nonce,

View file

@ -1,10 +1,7 @@
import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin'
import type { BinaryStreamOf } from './app-render'
import { htmlEscapeJsonString } from '../htmlescape'
import {
createDecodeTransformStream,
createEncodeTransformStream,
} from '../stream-utils/encode-decode'
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
@ -12,51 +9,18 @@ const INLINE_FLIGHT_PAYLOAD_BOOTSTRAP = 0
const INLINE_FLIGHT_PAYLOAD_DATA = 1
const INLINE_FLIGHT_PAYLOAD_FORM_STATE = 2
function createFlightTransformer(
nonce: string | undefined,
formState: unknown | null
) {
const startScriptTag = nonce
? `<script nonce=${JSON.stringify(nonce)}>`
: '<script>'
return new TransformStream<string, string>({
// Bootstrap the flight information.
start(controller) {
controller.enqueue(
`${startScriptTag}(self.__next_f=self.__next_f||[]).push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_BOOTSTRAP])
)});self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_FORM_STATE, formState])
)})</script>`
)
},
transform(chunk, controller) {
const scripts = `${startScriptTag}self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_DATA, chunk])
)})</script>`
controller.enqueue(scripts)
},
})
}
const flightResponses = new WeakMap<
ReadableStream<Uint8Array>,
Promise<JSX.Element>
>()
const flightResponses = new WeakMap<BinaryStreamOf<any>, Promise<any>>()
const encoder = new TextEncoder()
/**
* Render Flight stream.
* This is only used for renderToHTML, the Flight response does not need additional wrappers.
*/
export function useFlightResponse(
writable: WritableStream<Uint8Array>,
flightStream: ReadableStream<Uint8Array>,
export function useFlightStream<T>(
flightStream: BinaryStreamOf<T>,
clientReferenceManifest: ClientReferenceManifest,
formState: null | any,
nonce?: string
): Promise<JSX.Element> {
): Promise<T> {
const response = flightResponses.get(flightStream)
if (response) {
@ -76,8 +40,7 @@ export function useFlightResponse(
require('react-server-dom-webpack/client.edge').createFromReadableStream
}
const [renderStream, forwardStream] = flightStream.tee()
const res = createFromReadableStream(renderStream, {
const newResponse = createFromReadableStream(flightStream, {
ssrManifest: {
moduleLoading: clientReferenceManifest.moduleLoading,
moduleMap: isEdgeRuntime
@ -86,25 +49,117 @@ export function useFlightResponse(
},
nonce,
})
flightResponses.set(flightStream, res)
pipeFlightDataToInlinedStream(forwardStream, writable, nonce, formState)
flightResponses.set(flightStream, newResponse)
return res
return newResponse
}
function pipeFlightDataToInlinedStream(
/**
* There are times when an SSR render may be finished but the RSC render
* is ongoing and we need to wait for it to complete to make some determination
* about how to handle the render. This function will drain the RSC reader and
* resolve when completed. This will generally require teeing the RSC stream and it
* should be noted that it will cause all the RSC chunks to queue in the underlying
* ReadableStream however given Flight currently is a push stream that doesn't respond
* to backpressure this shouldn't change how much memory is maximally consumed
*/
export async function flightRenderComplete(
flightStream: ReadableStream<Uint8Array>
): Promise<void> {
const flightReader = flightStream.getReader()
while (true) {
const { done } = await flightReader.read()
if (done) {
return
}
}
}
/**
* Creates a ReadableStream provides inline script tag chunks for writing hydration
* data to the client outside the React render itself.
*
* @param flightStream The RSC render stream
* @param nonce optionally a nonce used during this particular render
* @param formState optionally the formState used with this particular render
* @returns a ReadableStream without the complete property. This signifies a lazy ReadableStream
*/
export function createInlinedDataReadableStream(
flightStream: ReadableStream<Uint8Array>,
writable: WritableStream<Uint8Array>,
nonce: string | undefined,
formState: unknown | null
): void {
flightStream
.pipeThrough(createDecodeTransformStream())
.pipeThrough(createFlightTransformer(nonce, formState))
.pipeThrough(createEncodeTransformStream())
.pipeTo(writable)
.catch((err) => {
console.error('Unexpected error while rendering Flight stream', err)
})
): ReadableStream<Uint8Array> {
const startScriptTag = nonce
? `<script nonce=${JSON.stringify(nonce)}>`
: '<script>'
const decoder = new TextDecoder('utf-8', { fatal: true })
const decoderOptions = { stream: true }
const flightReader = flightStream.getReader()
const readable = new ReadableStream({
type: 'bytes',
start(controller) {
try {
writeInitialInstructions(controller, startScriptTag, formState)
} catch (error) {
// during encoding or enqueueing forward the error downstream
controller.error(error)
}
},
async pull(controller) {
try {
const { done, value } = await flightReader.read()
if (done) {
const tail = decoder.decode(value, { stream: false })
if (tail.length) {
writeFlightDataInstruction(controller, startScriptTag, tail)
}
controller.close()
} else {
const chunkAsString = decoder.decode(value, decoderOptions)
writeFlightDataInstruction(controller, startScriptTag, chunkAsString)
}
} catch (error) {
// There was a problem in the upstream reader or during decoding or enqueuing
// forward the error downstream
controller.error(error)
}
},
})
return readable
}
function writeInitialInstructions(
controller: ReadableStreamDefaultController,
scriptStart: string,
formState: unknown | null
) {
controller.enqueue(
encoder.encode(
`${scriptStart}(self.__next_f=self.__next_f||[]).push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_BOOTSTRAP])
)});self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_FORM_STATE, formState])
)})</script>`
)
)
}
function writeFlightDataInstruction(
controller: ReadableStreamDefaultController,
scriptStart: string,
chunkAsString: string
) {
controller.enqueue(
encoder.encode(
`${scriptStart}self.__next_f.push(${htmlEscapeJsonString(
JSON.stringify([INLINE_FLIGHT_PAYLOAD_DATA, chunkAsString])
)})</script>`
)
)
}

View file

@ -3,6 +3,8 @@ import type { StaticGenerationStore } from '../../client/components/static-gener
import type { AsyncLocalStorage } from 'async_hooks'
import type { IncrementalCache } from '../lib/incremental-cache'
import { createPrerenderState } from '../../server/app-render/dynamic-rendering'
export type StaticGenerationContext = {
urlPathname: string
postpone?: (reason: string) => never
@ -65,9 +67,7 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper<
const prerenderState: StaticGenerationStore['prerenderState'] =
isStaticGeneration && renderOpts.experimental.ppr
? {
hasDynamic: false,
}
? createPrerenderState()
: null
const store: StaticGenerationStore = {

View file

@ -8,11 +8,3 @@ export function createDecodeTransformStream(decoder = new TextDecoder()) {
},
})
}
export function createEncodeTransformStream(encoder = new TextEncoder()) {
return new TransformStream<string, Uint8Array>({
transform(chunk, controller) {
return controller.enqueue(encoder.encode(chunk))
},
})
}

View file

@ -4,52 +4,64 @@ import { getTracer } from '../lib/trace/tracer'
import { AppRenderSpan } from '../lib/trace/constants'
import { createDecodeTransformStream } from './encode-decode'
import { DetachedPromise } from '../../lib/detached-promise'
import { scheduleImmediate } from '../../lib/scheduler'
import { scheduleImmediate, atLeastOneTask } from '../../lib/scheduler'
function voidCatch() {
// this catcher is designed to be used with pipeTo where we expect the underlying
// pipe implementation to forward errors but we don't want the pipeTo promise to reject
// and be unhandled
}
export type ReactReadableStream = ReadableStream<Uint8Array> & {
allReady?: Promise<void> | undefined
}
export function cloneTransformStream(source: TransformStream) {
const sourceReader = source.readable.getReader()
const clone = new TransformStream({
async start(controller) {
while (true) {
const { done, value } = await sourceReader.read()
if (done) {
break
}
controller.enqueue(value)
}
},
// skip all piped chunks
transform() {},
})
return clone
}
// We can share the same encoder instance everywhere
// Notably we cannot do the same for TextDecoder because it is stateful
// when handling streaming data
const encoder = new TextEncoder()
export function chainStreams<T>(
...streams: ReadableStream<T>[]
): ReadableStream<T> {
// We could encode this invariant in the arguments but current uses of this function pass
// use spread so it would be missed by
if (streams.length === 0) {
throw new Error('Invariant: chainStreams requires at least one stream')
}
// If we only have 1 stream we fast path it by returning just this stream
if (streams.length === 1) {
return streams[0]
}
const { readable, writable } = new TransformStream()
let promise = Promise.resolve()
for (let i = 0; i < streams.length; ++i) {
// We always initiate pipeTo immediately. We know we have at least 2 streams
// so we need to avoid closing the writable when this one finishes.
let promise = streams[0].pipeTo(writable, { preventClose: true })
let i = 1
for (; i < streams.length - 1; i++) {
const nextStream = streams[i]
promise = promise.then(() =>
streams[i].pipeTo(writable, { preventClose: i + 1 < streams.length })
nextStream.pipeTo(writable, { preventClose: true })
)
}
// We can omit the length check because we halted before the last stream and there
// is at least two streams so the lastStream here will always be defined
const lastStream = streams[i]
promise = promise.then(() => lastStream.pipeTo(writable))
// Catch any errors from the streams and ignore them, they will be handled
// by whatever is consuming the readable stream.
promise.catch(() => {})
promise.catch(voidCatch)
return readable
}
export function streamFromString(str: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(str))
@ -81,7 +93,8 @@ export function createBufferedTransformStream(): TransformStream<
Uint8Array,
Uint8Array
> {
let buffer: Uint8Array = new Uint8Array()
let bufferedChunks: Array<Uint8Array> = []
let bufferByteLength: number = 0
let pending: DetachedPromise<void> | undefined
const flush = (controller: TransformStreamDefaultController) => {
@ -93,8 +106,18 @@ export function createBufferedTransformStream(): TransformStream<
scheduleImmediate(() => {
try {
controller.enqueue(buffer)
buffer = new Uint8Array()
const chunk = new Uint8Array(bufferByteLength)
let copiedBytes = 0
for (let i = 0; i < bufferedChunks.length; i++) {
const bufferedChunk = bufferedChunks[i]
chunk.set(bufferedChunk, copiedBytes)
copiedBytes += bufferedChunk.byteLength
}
// We just wrote all the buffered chunks so we need to reset the bufferedChunks array
// and our bufferByteLength to prepare for the next round of buffered chunks
bufferedChunks.length = 0
bufferByteLength = 0
controller.enqueue(chunk)
} catch {
// If an error occurs while enqueuing it can't be due to this
// transformers fault. It's likely due to the controller being
@ -109,10 +132,8 @@ export function createBufferedTransformStream(): TransformStream<
return new TransformStream({
transform(chunk, controller) {
// Combine the previous buffer with the new chunk.
const combined = new Uint8Array(buffer.length + chunk.byteLength)
combined.set(buffer)
combined.set(chunk, buffer.length)
buffer = combined
bufferedChunks.push(chunk)
bufferByteLength += chunk.byteLength
// Flush the buffer to the controller.
flush(controller)
@ -128,7 +149,6 @@ export function createBufferedTransformStream(): TransformStream<
function createInsertedHTMLStream(
getServerInsertedHTML: () => Promise<string>
): TransformStream<Uint8Array, Uint8Array> {
const encoder = new TextEncoder()
return new TransformStream({
transform: async (chunk, controller) => {
const html = await getServerInsertedHTML()
@ -161,11 +181,15 @@ function createHeadInsertionTransformStream(
let inserted = false
let freezing = false
const encoder = new TextEncoder()
const decoder = new TextDecoder()
// We need to track if this transform saw any bytes because if it didn't
// we won't want to insert any server HTML at all
let hasBytes = false
return new TransformStream({
async transform(chunk, controller) {
hasBytes = true
// While react is flushing chunks, we don't apply insertions
if (freezing) {
controller.enqueue(chunk)
@ -199,9 +223,11 @@ function createHeadInsertionTransformStream(
},
async flush(controller) {
// Check before closing if there's anything remaining to insert.
const insertion = await insert()
if (insertion) {
controller.enqueue(encoder.encode(insertion))
if (hasBytes) {
const insertion = await insert()
if (insertion) {
controller.enqueue(encoder.encode(insertion))
}
}
},
})
@ -215,8 +241,6 @@ function createDeferredSuffixStream(
let flushed = false
let pending: DetachedPromise<void> | undefined
const encoder = new TextEncoder()
const flush = (controller: TransformStreamDefaultController) => {
const detached = new DetachedPromise<void>()
pending = detached
@ -261,10 +285,14 @@ function createDeferredSuffixStream(
function createMergedTransformStream(
stream: ReadableStream<Uint8Array>
): TransformStream<Uint8Array, Uint8Array> {
let started = false
let pending: DetachedPromise<void> | null = null
let pull: Promise<void> | null = null
let donePulling = false
async function startPulling(controller: TransformStreamDefaultController) {
if (pull) {
return
}
const start = (controller: TransformStreamDefaultController) => {
const reader = stream.getReader()
// NOTE: streaming flush
@ -273,26 +301,25 @@ function createMergedTransformStream(
// implementation, e.g. with a specific high-water mark. To ensure it's
// the safe timing to pipe the data stream, this extra tick is
// necessary.
const detached = new DetachedPromise<void>()
pending = detached
// We use `setTimeout/setImmediate` here to ensure that it's inserted after
// flushing the shell. Note that this implementation might get stale if impl
// details of Fizz change in the future.
scheduleImmediate(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) return
// We don't start reading until we've left the current Task to ensure
// that it's inserted after flushing the shell. Note that this implementation
// might get stale if impl details of Fizz change in the future.
await atLeastOneTask()
controller.enqueue(value)
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
donePulling = true
return
}
} catch (err) {
controller.error(err)
} finally {
detached.resolve()
controller.enqueue(value)
}
})
} catch (err) {
controller.error(err)
}
}
return new TransformStream({
@ -300,18 +327,15 @@ function createMergedTransformStream(
controller.enqueue(chunk)
// Start the streaming if it hasn't already been started yet.
if (started) return
started = true
start(controller)
if (!pull) {
pull = startPulling(controller)
}
},
flush() {
// If the data stream promise is defined, then return it as its completion
// will be the completion of the stream.
if (!pending) return
if (!started) return
return pending.promise
flush(controller) {
if (donePulling) {
return
}
return pull || startPulling(controller)
},
})
}
@ -326,7 +350,6 @@ function createMoveSuffixStream(
): TransformStream<Uint8Array, Uint8Array> {
let foundSuffix = false
const encoder = new TextEncoder()
const decoder = new TextDecoder()
return new TransformStream({
@ -371,6 +394,43 @@ function createMoveSuffixStream(
})
}
function createStripDocumentClosingTagsTransform(): TransformStream<
Uint8Array,
Uint8Array
> {
const decoder = new TextDecoder()
return new TransformStream({
transform(chunk, controller) {
// We rely on the assumption that chunks will never break across a code unit.
// This is reasonable because we currently concat all of React's output from a single
// flush into one chunk before streaming it forward which means the chunk will represent
// a single coherent utf-8 string. This is not safe to use if we change our streaming to no
// longer do this large buffered chunk
let originalContent = decoder.decode(chunk)
let content = originalContent
if (
content === '</body></html>' ||
content === '</body>' ||
content === '</html>'
) {
// the entire chunk is the closing tags.
return
} else {
// We assume these tags will go at together at the end of the document and that
// they won't appear anywhere else in the document. This is not really a safe assumption
// but until we revamp our streaming infra this is a performant way to string the tags
content = content.replace('</body>', '').replace('</html>', '')
if (content.length !== originalContent.length) {
return controller.enqueue(encoder.encode(content))
}
}
controller.enqueue(chunk)
},
})
}
export function createRootLayoutValidatorStream(
assetPrefix = '',
getTree: () => FlightRouterState
@ -378,7 +438,6 @@ export function createRootLayoutValidatorStream(
let foundHtml = false
let foundBody = false
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let content = ''
@ -454,7 +513,7 @@ export type ContinueStreamOptions = {
/**
* Suffix to inject after the buffered data, but before the close tags.
*/
suffix: string | undefined
suffix?: string | undefined
}
export async function continueFizzStream(
@ -515,44 +574,87 @@ export async function continueFizzStream(
])
}
type ContinuePostponedStreamOptions = Pick<
ContinueStreamOptions,
| 'inlinedDataStream'
| 'isStaticGeneration'
| 'getServerInsertedHTML'
| 'serverInsertedHTMLToHead'
>
type ContinueDynamicPrerenderOptions = {
getServerInsertedHTML: () => Promise<string>
}
export async function continuePostponedFizzStream(
renderStream: ReactReadableStream,
{
inlinedDataStream,
isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead,
}: ContinuePostponedStreamOptions
export async function continueDynamicPrerender(
prerenderStream: ReadableStream<Uint8Array>,
{ getServerInsertedHTML }: ContinueDynamicPrerenderOptions
) {
return (
prerenderStream
// Buffer everything to avoid flushing too frequently
.pipeThrough(createBufferedTransformStream())
.pipeThrough(createStripDocumentClosingTagsTransform())
// Insert generated tags to head
.pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML))
)
}
type ContinueStaticPrerenderOptions = {
inlinedDataStream: ReadableStream<Uint8Array>
getServerInsertedHTML: () => Promise<string>
}
export async function continueStaticPrerender(
prerenderStream: ReadableStream<Uint8Array>,
{ inlinedDataStream, getServerInsertedHTML }: ContinueStaticPrerenderOptions
) {
const closeTag = '</body></html>'
// If we're generating static HTML and there's an `allReady` promise on the
// stream, we need to wait for it to resolve before continuing.
if (isStaticGeneration && 'allReady' in renderStream) {
await renderStream.allReady
}
return chainTransformers(renderStream, [
// Buffer everything to avoid flushing too frequently
createBufferedTransformStream(),
// Insert generated tags to head
getServerInsertedHTML && !serverInsertedHTMLToHead
? createInsertedHTMLStream(getServerInsertedHTML)
: null,
// Insert the inlined data (Flight data, form state, etc.) stream into the HTML
inlinedDataStream ? createMergedTransformStream(inlinedDataStream) : null,
// Close tags should always be deferred to the end
createMoveSuffixStream(closeTag),
])
return (
prerenderStream
// Buffer everything to avoid flushing too frequently
.pipeThrough(createBufferedTransformStream())
// Insert generated tags to head
.pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML))
// Insert the inlined data (Flight data, form state, etc.) stream into the HTML
.pipeThrough(createMergedTransformStream(inlinedDataStream))
// Close tags should always be deferred to the end
.pipeThrough(createMoveSuffixStream(closeTag))
)
}
type ContinueResumeOptions = {
inlinedDataStream: ReadableStream<Uint8Array>
getServerInsertedHTML: () => Promise<string>
}
export async function continueDynamicHTMLResume(
renderStream: ReadableStream<Uint8Array>,
{ inlinedDataStream, getServerInsertedHTML }: ContinueResumeOptions
) {
const closeTag = '</body></html>'
return (
renderStream
// Buffer everything to avoid flushing too frequently
.pipeThrough(createBufferedTransformStream())
// Insert generated tags to head
.pipeThrough(createHeadInsertionTransformStream(getServerInsertedHTML))
// Insert the inlined data (Flight data, form state, etc.) stream into the HTML
.pipeThrough(createMergedTransformStream(inlinedDataStream))
// Close tags should always be deferred to the end
.pipeThrough(createMoveSuffixStream(closeTag))
)
}
type ContinueDynamicDataResumeOptions = {
inlinedDataStream: ReadableStream<Uint8Array>
}
export async function continueDynamicDataResume(
renderStream: ReadableStream<Uint8Array>,
{ inlinedDataStream }: ContinueDynamicDataResumeOptions
) {
const closeTag = '</body></html>'
return (
renderStream
// Insert the inlined data (Flight data, form state, etc.) stream into the HTML
.pipeThrough(createMergedTransformStream(inlinedDataStream))
// Close tags should always be deferred to the end
.pipeThrough(createMoveSuffixStream(closeTag))
)
}

View file

@ -1,6 +1,7 @@
import { createNextDescribe, FileRef } from 'e2e-utils'
import { getRedboxSource, hasRedbox } from 'next-test-utils'
import { join } from 'path'
import cheerio from 'cheerio'
// TODO-APP: due to a current implementation limitation, we don't have proper tree
// shaking when across the server/client boundaries (e.g. all referenced client
@ -291,28 +292,30 @@ describe('app dir - next/font', () => {
if (!isDev) {
describe('preload', () => {
it('should preload correctly with server components', async () => {
const $ = await next.render$('/')
const result = await next.fetch('/')
const headers = result.headers
const html = await result.text()
const $ = cheerio.load(html)
// Preconnect
expect($('link[rel="preconnect"]').length).toBe(0)
const fontPreloadlinksInHeaders = headers
.get('link')
.split(', ')
.filter((link) => link.match(/as=.*font/))
expect(fontPreloadlinksInHeaders.length).toBeGreaterThan(2)
for (const link of fontPreloadlinksInHeaders) {
expect(link).toMatch(/<[^>]*?_next[^>]*?\.woff2>/)
expect(link).toMatch(/rel=.*preload/)
expect(link).toMatch(/crossorigin=""/)
}
const items = getAttrs($('link[as="font"]'))
for (const item of items) {
expect(item.as).toBe('font')
expect(item.crossorigin).toBe('')
if (process.env.TURBOPACK) {
expect(item.href).toMatch(
/\/_next\/static\/media\/(.*)-s.p.(.*)\.woff2/
)
} else {
expect(item.href).toMatch(
/\/_next\/static\/media\/(.*)-s.p.woff2/
)
}
expect(item.rel).toBe('preload')
expect(item.type).toBe('font/woff2')
}
// We expect the font preloads to be in headers exclusively
expect(items.length).toBe(0)
})
it('should preload correctly with client components', async () => {

View file

@ -19,32 +19,9 @@ describe('ppr build errors', () => {
expect(stderr).toContain(
'Error occurred prerendering page "/regular-error-suspense-boundary".'
)
})
describe('when a postpone call was made but missing postpone data', () => {
it('should fail the build', async () => {
expect(stderr).toContain(
'Prerendering / needs to partially bail out because something dynamic was used. '
)
})
it('should fail the build & surface any errors that were thrown by user code', async () => {
// in the case of catching a postpone and throwing a new error, we log the error that the user threw to help with debugging
expect(stderr).toContain(
'Prerendering /re-throwing-error needs to partially bail out because something dynamic was used. '
)
expect(stderr).toContain(
'The following error was thrown during build, and may help identify the source of the issue:'
)
expect(stderr).toContain(
'Error: The original error was caught and rethrown.'
)
// the regular pre-render error should not be thrown as well, as we've already logged a more specific error
expect(stderr).not.toContain(
'Error occurred prerendering page "/re-throwing-error"'
)
})
expect(stderr).toContain(
'Error occurred prerendering page "/re-throwing-error".'
)
})
})
@ -53,37 +30,9 @@ describe('ppr build errors', () => {
expect(stderr).toContain(
'Error occurred prerendering page "/regular-error".'
)
})
describe('when a postpone call was made but missing postpone data', () => {
it('should fail the build', async () => {
expect(stderr).toContain(
'Prerendering /no-suspense-boundary needs to partially bail out because something dynamic was used. '
)
// the regular pre-render error should not be thrown as well, as we've already logged a more specific error
expect(stderr).not.toContain(
'Error occurred prerendering page "/no-suspense-boundary"'
)
})
it('should fail the build & surface any errors that were thrown by user code', async () => {
// in the case of catching a postpone and throwing a new error, we log the error that the user threw to help with debugging
expect(stderr).toContain(
'Prerendering /no-suspense-boundary-re-throwing-error needs to partially bail out because something dynamic was used. '
)
expect(stderr).toContain(
'The following error was thrown during build, and may help identify the source of the issue:'
)
expect(stderr).toContain(
"Error: Throwing a new error from 'no-suspense-boundary-re-throwing-error'"
)
// the regular pre-render error should not be thrown as well, as we've already logged a more specific error
expect(stderr).not.toContain(
'Error occurred prerendering page "/no-suspense-boundary-re-throwing-error"'
)
})
expect(stderr).toContain(
'Error occurred prerendering page "/no-suspense-boundary-re-throwing-error".'
)
})
})

View file

@ -0,0 +1,16 @@
import { Suspense } from 'react'
import { Optimistic } from '../../../components/optimistic'
import { ServerHtml } from '../../../components/server-html'
export const dynamic = 'force-dynamic'
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
</>
)
}

View file

@ -0,0 +1,17 @@
import { Suspense } from 'react'
import { Optimistic } from '../../../components/optimistic'
import { ServerHtml } from '../../../components/server-html'
export const dynamic = 'force-static'
export const revalidate = 60
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
</>
)
}

View file

@ -0,0 +1,28 @@
import { Suspense, unstable_postpone as postpone } from 'react'
import { Optimistic } from '../../../../components/optimistic'
import { ServerHtml } from '../../../../components/server-html'
export const dynamic = 'force-dynamic'
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
<Suspense fallback="loading...">
<IncidentalPostpone />
</Suspense>
</>
)
}
function IncidentalPostpone() {
// This component will postpone but is not using
// any dynamic APIs so we expect it to simply client render
if (typeof window === 'undefined') {
postpone('incidentally')
}
return <div>Incidental</div>
}

View file

@ -0,0 +1,28 @@
import { Suspense, unstable_postpone as postpone } from 'react'
import { Optimistic } from '../../../../components/optimistic'
import { ServerHtml } from '../../../../components/server-html'
export const dynamic = 'force-static'
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
<Suspense fallback="loading...">
<IncidentalPostpone />
</Suspense>
</>
)
}
function IncidentalPostpone() {
// This component will postpone but is not using
// any dynamic APIs so we expect it to simply client render
if (typeof window === 'undefined') {
postpone('incidentally')
}
return <div>Incidental</div>
}

View file

@ -0,0 +1,26 @@
import { Suspense, unstable_postpone as postpone } from 'react'
import { Optimistic } from '../../../components/optimistic'
import { ServerHtml } from '../../../components/server-html'
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
<Suspense fallback="loading...">
<IncidentalPostpone />
</Suspense>
</>
)
}
function IncidentalPostpone() {
// This component will postpone but is not using
// any dynamic APIs so we expect it to simply client render
if (typeof window === 'undefined') {
postpone('incidentally')
}
return <div>Incidental</div>
}

View file

@ -0,0 +1,14 @@
import { Suspense } from 'react'
import { Optimistic } from '../../components/optimistic'
import { ServerHtml } from '../../components/server-html'
export default ({ searchParams }) => {
return (
<>
<ServerHtml />
<Suspense fallback="loading...">
<Optimistic searchParams={searchParams} />
</Suspense>
</>
)
}

View file

@ -0,0 +1,7 @@
export async function Optimistic({ searchParams }) {
try {
return <div id="foosearch">foo search: {searchParams.foo}</div>
} catch (err) {
return <div id="foosearch">foo search: optimistic</div>
}
}

View file

@ -0,0 +1,15 @@
'use client'
import { useRef } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
export function ServerHtml() {
const ref = useRef(0)
useServerInsertedHTML(() => {
console.log('useServerInsertedHTML')
return null
})
useServerInsertedHTML(() => (
<meta name="server-html" content={ref.current++} />
))
}

View file

@ -412,6 +412,162 @@ createNextDescribe(
}
})
})
describe('Dynamic Data pages', () => {
describe('Optimistic UI', () => {
it('should initially render with optimistic UI', async () => {
const $ = await next.render$('/dynamic-data?foo=bar')
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to be the optimistic output
expect($('#foosearch').text()).toEqual('foo search: optimistic')
// We expect hydration to patch up the render with dynamic data
// from the resume
const browser = await next.browser('/dynamic-data?foo=bar')
try {
await browser.waitForElementByCss('#foosearch')
expect(
await browser.eval(
'document.getElementById("foosearch").textContent'
)
).toEqual('foo search: bar')
} finally {
await browser.close()
}
})
it('should render entirely statically with force-static', async () => {
const $ = await next.render$('/dynamic-data/force-static?foo=bar')
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to be forced static so no params
// were made available but also nothing threw and was caught for
// optimistic UI
expect($('#foosearch').text()).toEqual('foo search: ')
// There is no hydration mismatch, we continue to have empty searchParmas
const browser = await next.browser(
'/dynamic-data/force-static?foo=bar'
)
try {
await browser.waitForElementByCss('#foosearch')
expect(
await browser.eval(
'document.getElementById("foosearch").textContent'
)
).toEqual('foo search: ')
} finally {
await browser.close()
}
})
it('should render entirely dynamically when force-dynamic', async () => {
const $ = await next.render$('/dynamic-data/force-dynamic?foo=bar')
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to render dynamically
expect($('#foosearch').text()).toEqual('foo search: bar')
})
})
describe('Incidental postpones', () => {
it('should initially render with optimistic UI', async () => {
const $ = await next.render$(
'/dynamic-data/incidental-postpone?foo=bar'
)
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to be the optimistic output
expect($('#foosearch').text()).toEqual('foo search: optimistic')
// We expect hydration to patch up the render with dynamic data
// from the resume
const browser = await next.browser(
'/dynamic-data/incidental-postpone?foo=bar'
)
try {
await browser.waitForElementByCss('#foosearch')
expect(
await browser.eval(
'document.getElementById("foosearch").textContent'
)
).toEqual('foo search: bar')
} finally {
await browser.close()
}
})
it('should render entirely statically with force-static', async () => {
const $ = await next.render$(
'/dynamic-data/incidental-postpone/force-static?foo=bar'
)
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to be forced static so no params
// were made available but also nothing threw and was caught for
// optimistic UI
expect($('#foosearch').text()).toEqual('foo search: ')
// There is no hydration mismatch, we continue to have empty searchParmas
const browser = await next.browser(
'/dynamic-data/incidental-postpone/force-static?foo=bar'
)
try {
await browser.waitForElementByCss('#foosearch')
expect(
await browser.eval(
'document.getElementById("foosearch").textContent'
)
).toEqual('foo search: ')
} finally {
await browser.close()
}
})
it('should render entirely dynamically when force-dynamic', async () => {
const $ = await next.render$(
'/dynamic-data/incidental-postpone/force-dynamic?foo=bar'
)
// We defined some server html let's make sure it flushed both in the head
// There may be additional flushes in the body but we want to ensure that
// server html is getting inserted in the shell correctly here
const serverHTML = $('head meta[name="server-html"]')
expect(serverHTML.length).toEqual(1)
expect($(serverHTML[0]).attr('content')).toEqual('0')
// We expect the server HTML to render dynamically
expect($('#foosearch').text()).toEqual('foo search: bar')
})
})
})
}
}
)