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:
Shu Ding 2021-11-15 16:29:34 +01:00 committed by GitHub
parent 7d82e07df8
commit 16d56e2c49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 180 additions and 114 deletions

View file

@ -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(

View file

@ -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()
})
}

View file

@ -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' {