Refactor server/render for SSR streaming (#31231)
Initial step to refactor the rendering logic by decoupling the handler and renderer: 1. Delegate Flight rendering to server/render 2. Reuse the piper glue code for both Fizz and Flight streams 3. Add buffering for ReadableStream In 1), this PR also makes sure that gSSP/gSP are correctly executed before the Flight stream and `pageProps` and `router` are correctly delivered to the component. Related to #30994. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint`
This commit is contained in:
parent
7d82e07df8
commit
16d56e2c49
3 changed files with 180 additions and 114 deletions
|
@ -28,16 +28,6 @@ export default async function middlewareRSCLoader(this: any) {
|
|||
import { RouterContext } from 'next/dist/shared/lib/router-context'
|
||||
import { renderToHTML } from 'next/dist/server/web/render'
|
||||
|
||||
import React, { createElement } from 'react'
|
||||
|
||||
${
|
||||
isServerComponent
|
||||
? `
|
||||
import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server'
|
||||
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'`
|
||||
: ''
|
||||
}
|
||||
|
||||
${appDefinition}
|
||||
${documentDefinition}
|
||||
|
||||
|
@ -57,47 +47,7 @@ export default async function middlewareRSCLoader(this: any) {
|
|||
throw new Error('Your page must export a \`default\` component')
|
||||
}
|
||||
|
||||
function wrapReadable(readable) {
|
||||
const encoder = new TextEncoder()
|
||||
const transformStream = new TransformStream()
|
||||
const writer = transformStream.writable.getWriter()
|
||||
const reader = readable.getReader()
|
||||
const process = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (!done) {
|
||||
writer.write(typeof value === 'string' ? encoder.encode(value) : value)
|
||||
process()
|
||||
} else {
|
||||
writer.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
process()
|
||||
return transformStream.readable
|
||||
}
|
||||
|
||||
${
|
||||
isServerComponent
|
||||
? `
|
||||
const renderFlight = props => renderToReadableStream(createElement(Page, props), rscManifest)
|
||||
|
||||
let responseCache
|
||||
const FlightWrapper = props => {
|
||||
let response = responseCache
|
||||
if (!response) {
|
||||
responseCache = response = createFromReadableStream(renderFlight(props))
|
||||
}
|
||||
return response.readRoot()
|
||||
}
|
||||
const Component = props => {
|
||||
return createElement(
|
||||
React.Suspense,
|
||||
{ fallback: null },
|
||||
createElement(FlightWrapper, props)
|
||||
)
|
||||
}`
|
||||
: `const Component = Page`
|
||||
}
|
||||
const Component = Page
|
||||
|
||||
async function render(request) {
|
||||
const url = request.nextUrl
|
||||
|
@ -111,28 +61,10 @@ export default async function middlewareRSCLoader(this: any) {
|
|||
})
|
||||
}
|
||||
|
||||
${
|
||||
isServerComponent
|
||||
? `
|
||||
// Flight data request
|
||||
const isFlightDataRequest = query.__flight__ !== undefined
|
||||
if (isFlightDataRequest) {
|
||||
delete query.__flight__
|
||||
return new Response(
|
||||
wrapReadable(
|
||||
renderFlight({
|
||||
router: {
|
||||
route: pathname,
|
||||
asPath: pathname,
|
||||
pathname: pathname,
|
||||
query,
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}`
|
||||
: ''
|
||||
const renderServerComponentData = ${
|
||||
isServerComponent ? `query.__flight__ !== undefined` : 'false'
|
||||
}
|
||||
delete query.__flight__
|
||||
|
||||
const renderOpts = {
|
||||
Component,
|
||||
|
@ -156,7 +88,10 @@ export default async function middlewareRSCLoader(this: any) {
|
|||
basePath: ${JSON.stringify(basePath || '')},
|
||||
supportsDynamicHTML: true,
|
||||
concurrentFeatures: true,
|
||||
renderServerComponent: ${isServerComponent ? 'true' : 'false'},
|
||||
renderServerComponentData,
|
||||
serverComponentManifest: ${
|
||||
isServerComponent ? 'rscManifest' : 'null'
|
||||
},
|
||||
}
|
||||
|
||||
const transformStream = new TransformStream()
|
||||
|
@ -173,7 +108,8 @@ export default async function middlewareRSCLoader(this: any) {
|
|||
)
|
||||
result.pipe({
|
||||
write: str => writer.write(encoder.encode(str)),
|
||||
end: () => writer.close()
|
||||
end: () => writer.close(),
|
||||
// Not implemented: cork/uncork/on/removeListener
|
||||
})
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
|
|
|
@ -3,6 +3,8 @@ import { ParsedUrlQuery } from 'querystring'
|
|||
import type { Writable as WritableType } from 'stream'
|
||||
import React from 'react'
|
||||
import ReactDOMServer from 'react-dom/server'
|
||||
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'
|
||||
import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server'
|
||||
import { StyleRegistry, createStyleRegistry } from 'styled-jsx'
|
||||
import { UnwrapPromise } from '../lib/coalesced-function'
|
||||
import {
|
||||
|
@ -203,7 +205,8 @@ export type RenderOptsPartial = {
|
|||
devOnlyCacheBusterQueryString?: string
|
||||
resolvedUrl?: string
|
||||
resolvedAsPath?: string
|
||||
renderServerComponent?: null | (() => Promise<string>)
|
||||
serverComponentManifest?: any
|
||||
renderServerComponentData?: boolean
|
||||
distDir?: string
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
|
@ -274,6 +277,49 @@ function checkRedirectValues(
|
|||
}
|
||||
}
|
||||
|
||||
// Create the wrapper component for a Flight stream.
|
||||
function createServerComponentRenderer(
|
||||
OriginalComponent: React.ComponentType,
|
||||
serverComponentManifest: NonNullable<RenderOpts['serverComponentManifest']>
|
||||
) {
|
||||
let responseCache: any
|
||||
const ServerComponentWrapper = (props: any) => {
|
||||
let response = responseCache
|
||||
if (!response) {
|
||||
responseCache = response = createFromReadableStream(
|
||||
renderToReadableStream(
|
||||
<OriginalComponent {...props} />,
|
||||
serverComponentManifest
|
||||
)
|
||||
)
|
||||
}
|
||||
return response.readRoot()
|
||||
}
|
||||
const Component = (props: any) => {
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<ServerComponentWrapper {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Although it's not allowed to attach some static methods to Component,
|
||||
// we still re-assign all the component APIs to keep the behavior unchanged.
|
||||
for (const methodName of [
|
||||
'getInitialProps',
|
||||
'getStaticProps',
|
||||
'getServerSideProps',
|
||||
'getStaticPaths',
|
||||
]) {
|
||||
const method = (OriginalComponent as any)[methodName]
|
||||
if (method) {
|
||||
;(Component as any)[methodName] = method
|
||||
}
|
||||
}
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
export async function renderToHTML(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
|
@ -298,7 +344,6 @@ export async function renderToHTML(
|
|||
App,
|
||||
Document,
|
||||
pageConfig = {},
|
||||
Component,
|
||||
buildManifest,
|
||||
fontManifest,
|
||||
reactLoadableManifest,
|
||||
|
@ -306,7 +351,8 @@ export async function renderToHTML(
|
|||
getStaticProps,
|
||||
getStaticPaths,
|
||||
getServerSideProps,
|
||||
renderServerComponent,
|
||||
serverComponentManifest,
|
||||
renderServerComponentData,
|
||||
isDataReq,
|
||||
params,
|
||||
previewProps,
|
||||
|
@ -316,6 +362,12 @@ export async function renderToHTML(
|
|||
concurrentFeatures,
|
||||
} = renderOpts
|
||||
|
||||
const isServerComponent = !!serverComponentManifest
|
||||
const OriginalComponent = renderOpts.Component
|
||||
const Component = isServerComponent
|
||||
? createServerComponentRenderer(OriginalComponent, serverComponentManifest)
|
||||
: renderOpts.Component
|
||||
|
||||
const getFontDefinition = (url: string): string => {
|
||||
if (fontManifest) {
|
||||
return getFontDefinitionFromManifest(url, fontManifest)
|
||||
|
@ -359,8 +411,6 @@ export async function renderToHTML(
|
|||
|
||||
const hasPageGetInitialProps = !!(Component as any).getInitialProps
|
||||
|
||||
const isRSC = !!renderServerComponent
|
||||
|
||||
const pageIsDynamic = isDynamicRoute(pathname)
|
||||
|
||||
const isAutoExport =
|
||||
|
@ -940,9 +990,29 @@ export async function renderToHTML(
|
|||
props.pageProps = {}
|
||||
}
|
||||
|
||||
// Pass router to the Server Component as a temporary workaround.
|
||||
if (isServerComponent) {
|
||||
props.pageProps = Object.assign({}, props.pageProps, { router })
|
||||
}
|
||||
|
||||
// the response might be finished on the getInitialProps call
|
||||
if (isResSent(res) && !isSSG) return null
|
||||
|
||||
if (renderServerComponentData) {
|
||||
return new RenderResult((res_, next) => {
|
||||
const { startWriting } = connectReactServerReadableStreamToPiper(
|
||||
res_.write,
|
||||
next
|
||||
)
|
||||
startWriting(
|
||||
renderToReadableStream(
|
||||
<OriginalComponent {...props} />,
|
||||
serverComponentManifest
|
||||
).getReader()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// we preload the buildManifest for auto-export dynamic pages
|
||||
// to speed up hydrating query values
|
||||
let filteredBuildManifest = buildManifest
|
||||
|
@ -1081,7 +1151,7 @@ export async function renderToHTML(
|
|||
|
||||
return concurrentFeatures
|
||||
? process.browser
|
||||
? await renderToReadableStream(content)
|
||||
? await renderToWebStream(content)
|
||||
: await renderToNodeStream(content, generateStaticHTML)
|
||||
: piperFromArray([ReactDOMServer.renderToString(content)])
|
||||
}
|
||||
|
@ -1154,7 +1224,7 @@ export async function renderToHTML(
|
|||
err: renderOpts.err ? serializeError(dev, renderOpts.err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
|
||||
gsp: !!getStaticProps ? true : undefined, // whether the page is getStaticProps
|
||||
gssp: !!getServerSideProps ? true : undefined, // whether the page is getServerSideProps
|
||||
rsc: isRSC ? true : undefined, // whether the page is a server components page
|
||||
rsc: isServerComponent ? true : undefined, // whether the page is a server components page
|
||||
customServer, // whether the user is using a custom server
|
||||
gip: hasPageGetInitialProps ? true : undefined, // whether the page has getInitialProps
|
||||
appGip: !defaultAppGetInitialProps ? true : undefined, // whether the _app has getInitialProps
|
||||
|
@ -1467,49 +1537,107 @@ function renderToNodeStream(
|
|||
})
|
||||
}
|
||||
|
||||
function renderToReadableStream(
|
||||
function connectReactServerReadableStreamToPiper(
|
||||
write: (s: string) => void,
|
||||
next: (err?: Error) => void
|
||||
) {
|
||||
let bufferedString = ''
|
||||
let flushTimeout: null | NodeJS.Timeout = null
|
||||
|
||||
function flushBuffer() {
|
||||
// Intentionally delayed writing when using ReadableStream due to the lack
|
||||
// of cork/uncork APIs.
|
||||
if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(() => {
|
||||
write(bufferedString)
|
||||
bufferedString = ''
|
||||
flushTimeout = null
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function startWriting(reader: ReadableStreamDefaultReader) {
|
||||
const decoder = new TextDecoder()
|
||||
const process = () => {
|
||||
reader.read().then(({ done, value }: any) => {
|
||||
if (!done) {
|
||||
const s = typeof value === 'string' ? value : decoder.decode(value)
|
||||
bufferedString += s
|
||||
flushBuffer()
|
||||
process()
|
||||
} else {
|
||||
// Make sure it's scheduled after the current flushing.
|
||||
setTimeout(() => next(), 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
process()
|
||||
}
|
||||
|
||||
return {
|
||||
startWriting,
|
||||
}
|
||||
}
|
||||
|
||||
function renderToWebStream(
|
||||
element: React.ReactElement
|
||||
): Promise<NodeWritablePiper> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let reader: any = null
|
||||
let resolved = false
|
||||
let underlyingStream: {
|
||||
write: (s: string) => void
|
||||
next: (err?: Error) => void
|
||||
} | null = null
|
||||
|
||||
const doResolve = () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
const piper: NodeWritablePiper = (res, next) => {
|
||||
const streamReader: ReadableStreamDefaultReader = reader
|
||||
const decoder = new TextDecoder()
|
||||
const process = async () => {
|
||||
streamReader.read().then(({ done, value }) => {
|
||||
if (!done) {
|
||||
const s =
|
||||
typeof value === 'string' ? value : decoder.decode(value)
|
||||
res.write(s)
|
||||
process()
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
resolve((res, next) => {
|
||||
underlyingStream = {
|
||||
write: res.write,
|
||||
next,
|
||||
}
|
||||
process()
|
||||
}
|
||||
resolve(piper)
|
||||
})
|
||||
}
|
||||
|
||||
const readable = (ReactDOMServer as any).renderToReadableStream(element, {
|
||||
onError(err: Error) {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
reject(err)
|
||||
const { startWriting } = connectReactServerReadableStreamToPiper(
|
||||
(s: string) => {
|
||||
if (!underlyingStream) {
|
||||
throw new Error(
|
||||
'invariant: `write` called without an underlying stream. This is a bug in Next.js'
|
||||
)
|
||||
}
|
||||
underlyingStream.write(s)
|
||||
},
|
||||
onCompleteShell() {
|
||||
doResolve()
|
||||
},
|
||||
})
|
||||
// Start reader and lock stream immediately to consume readable,
|
||||
// Otherwise the bytes before `onCompleteShell` will be missed.
|
||||
reader = readable.getReader()
|
||||
(err) => {
|
||||
if (!underlyingStream) {
|
||||
throw new Error(
|
||||
'invariant: `next` called without an underlying stream. This is a bug in Next.js'
|
||||
)
|
||||
}
|
||||
underlyingStream.next(err)
|
||||
}
|
||||
)
|
||||
|
||||
const reader = (ReactDOMServer as any)
|
||||
.renderToReadableStream(element, {
|
||||
onError(err: Error) {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
onCompleteShell() {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
doResolve()
|
||||
// Queue startWriting in microtasks to make sure reader is
|
||||
// initialized.
|
||||
Promise.resolve().then(() => {
|
||||
startWriting(reader)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
.getReader()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
2
packages/next/types/misc.d.ts
vendored
2
packages/next/types/misc.d.ts
vendored
|
@ -1,6 +1,8 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
declare module 'next/dist/compiled/babel/plugin-transform-modules-commonjs'
|
||||
declare module 'next/dist/compiled/babel/plugin-syntax-jsx'
|
||||
declare module 'next/dist/compiled/react-server-dom-webpack'
|
||||
declare module 'next/dist/compiled/react-server-dom-webpack/writer.browser.server'
|
||||
declare module 'browserslist'
|
||||
|
||||
declare module 'cssnano-simple' {
|
||||
|
|
Loading…
Reference in a new issue