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:
parent
1e1f77426d
commit
ff7c5c2ba3
25 changed files with 1098 additions and 557 deletions
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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".'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
14
test/e2e/app-dir/ppr-full/app/dynamic-data/page.jsx
Normal file
14
test/e2e/app-dir/ppr-full/app/dynamic-data/page.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
7
test/e2e/app-dir/ppr-full/components/optimistic.jsx
Normal file
7
test/e2e/app-dir/ppr-full/components/optimistic.jsx
Normal 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>
|
||||
}
|
||||
}
|
15
test/e2e/app-dir/ppr-full/components/server-html.jsx
Normal file
15
test/e2e/app-dir/ppr-full/components/server-html.jsx
Normal 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++} />
|
||||
))
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue