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 { RouterContext } from 'next/dist/shared/lib/router-context'
|
||||||
import { renderToHTML } from 'next/dist/server/web/render'
|
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}
|
${appDefinition}
|
||||||
${documentDefinition}
|
${documentDefinition}
|
||||||
|
|
||||||
|
@ -57,47 +47,7 @@ export default async function middlewareRSCLoader(this: any) {
|
||||||
throw new Error('Your page must export a \`default\` component')
|
throw new Error('Your page must export a \`default\` component')
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapReadable(readable) {
|
const Component = Page
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(request) {
|
async function render(request) {
|
||||||
const url = request.nextUrl
|
const url = request.nextUrl
|
||||||
|
@ -111,28 +61,10 @@ export default async function middlewareRSCLoader(this: any) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
${
|
const renderServerComponentData = ${
|
||||||
isServerComponent
|
isServerComponent ? `query.__flight__ !== undefined` : 'false'
|
||||||
? `
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}`
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
|
delete query.__flight__
|
||||||
|
|
||||||
const renderOpts = {
|
const renderOpts = {
|
||||||
Component,
|
Component,
|
||||||
|
@ -156,7 +88,10 @@ export default async function middlewareRSCLoader(this: any) {
|
||||||
basePath: ${JSON.stringify(basePath || '')},
|
basePath: ${JSON.stringify(basePath || '')},
|
||||||
supportsDynamicHTML: true,
|
supportsDynamicHTML: true,
|
||||||
concurrentFeatures: true,
|
concurrentFeatures: true,
|
||||||
renderServerComponent: ${isServerComponent ? 'true' : 'false'},
|
renderServerComponentData,
|
||||||
|
serverComponentManifest: ${
|
||||||
|
isServerComponent ? 'rscManifest' : 'null'
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformStream = new TransformStream()
|
const transformStream = new TransformStream()
|
||||||
|
@ -173,7 +108,8 @@ export default async function middlewareRSCLoader(this: any) {
|
||||||
)
|
)
|
||||||
result.pipe({
|
result.pipe({
|
||||||
write: str => writer.write(encoder.encode(str)),
|
write: str => writer.write(encoder.encode(str)),
|
||||||
end: () => writer.close()
|
end: () => writer.close(),
|
||||||
|
// Not implemented: cork/uncork/on/removeListener
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { ParsedUrlQuery } from 'querystring'
|
||||||
import type { Writable as WritableType } from 'stream'
|
import type { Writable as WritableType } from 'stream'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOMServer from 'react-dom/server'
|
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 { StyleRegistry, createStyleRegistry } from 'styled-jsx'
|
||||||
import { UnwrapPromise } from '../lib/coalesced-function'
|
import { UnwrapPromise } from '../lib/coalesced-function'
|
||||||
import {
|
import {
|
||||||
|
@ -203,7 +205,8 @@ export type RenderOptsPartial = {
|
||||||
devOnlyCacheBusterQueryString?: string
|
devOnlyCacheBusterQueryString?: string
|
||||||
resolvedUrl?: string
|
resolvedUrl?: string
|
||||||
resolvedAsPath?: string
|
resolvedAsPath?: string
|
||||||
renderServerComponent?: null | (() => Promise<string>)
|
serverComponentManifest?: any
|
||||||
|
renderServerComponentData?: boolean
|
||||||
distDir?: string
|
distDir?: string
|
||||||
locale?: string
|
locale?: string
|
||||||
locales?: 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(
|
export async function renderToHTML(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
|
@ -298,7 +344,6 @@ export async function renderToHTML(
|
||||||
App,
|
App,
|
||||||
Document,
|
Document,
|
||||||
pageConfig = {},
|
pageConfig = {},
|
||||||
Component,
|
|
||||||
buildManifest,
|
buildManifest,
|
||||||
fontManifest,
|
fontManifest,
|
||||||
reactLoadableManifest,
|
reactLoadableManifest,
|
||||||
|
@ -306,7 +351,8 @@ export async function renderToHTML(
|
||||||
getStaticProps,
|
getStaticProps,
|
||||||
getStaticPaths,
|
getStaticPaths,
|
||||||
getServerSideProps,
|
getServerSideProps,
|
||||||
renderServerComponent,
|
serverComponentManifest,
|
||||||
|
renderServerComponentData,
|
||||||
isDataReq,
|
isDataReq,
|
||||||
params,
|
params,
|
||||||
previewProps,
|
previewProps,
|
||||||
|
@ -316,6 +362,12 @@ export async function renderToHTML(
|
||||||
concurrentFeatures,
|
concurrentFeatures,
|
||||||
} = renderOpts
|
} = renderOpts
|
||||||
|
|
||||||
|
const isServerComponent = !!serverComponentManifest
|
||||||
|
const OriginalComponent = renderOpts.Component
|
||||||
|
const Component = isServerComponent
|
||||||
|
? createServerComponentRenderer(OriginalComponent, serverComponentManifest)
|
||||||
|
: renderOpts.Component
|
||||||
|
|
||||||
const getFontDefinition = (url: string): string => {
|
const getFontDefinition = (url: string): string => {
|
||||||
if (fontManifest) {
|
if (fontManifest) {
|
||||||
return getFontDefinitionFromManifest(url, fontManifest)
|
return getFontDefinitionFromManifest(url, fontManifest)
|
||||||
|
@ -359,8 +411,6 @@ export async function renderToHTML(
|
||||||
|
|
||||||
const hasPageGetInitialProps = !!(Component as any).getInitialProps
|
const hasPageGetInitialProps = !!(Component as any).getInitialProps
|
||||||
|
|
||||||
const isRSC = !!renderServerComponent
|
|
||||||
|
|
||||||
const pageIsDynamic = isDynamicRoute(pathname)
|
const pageIsDynamic = isDynamicRoute(pathname)
|
||||||
|
|
||||||
const isAutoExport =
|
const isAutoExport =
|
||||||
|
@ -940,9 +990,29 @@ export async function renderToHTML(
|
||||||
props.pageProps = {}
|
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
|
// the response might be finished on the getInitialProps call
|
||||||
if (isResSent(res) && !isSSG) return null
|
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
|
// we preload the buildManifest for auto-export dynamic pages
|
||||||
// to speed up hydrating query values
|
// to speed up hydrating query values
|
||||||
let filteredBuildManifest = buildManifest
|
let filteredBuildManifest = buildManifest
|
||||||
|
@ -1081,7 +1151,7 @@ export async function renderToHTML(
|
||||||
|
|
||||||
return concurrentFeatures
|
return concurrentFeatures
|
||||||
? process.browser
|
? process.browser
|
||||||
? await renderToReadableStream(content)
|
? await renderToWebStream(content)
|
||||||
: await renderToNodeStream(content, generateStaticHTML)
|
: await renderToNodeStream(content, generateStaticHTML)
|
||||||
: piperFromArray([ReactDOMServer.renderToString(content)])
|
: 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
|
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
|
gsp: !!getStaticProps ? true : undefined, // whether the page is getStaticProps
|
||||||
gssp: !!getServerSideProps ? true : undefined, // whether the page is getServerSideProps
|
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
|
customServer, // whether the user is using a custom server
|
||||||
gip: hasPageGetInitialProps ? true : undefined, // whether the page has getInitialProps
|
gip: hasPageGetInitialProps ? true : undefined, // whether the page has getInitialProps
|
||||||
appGip: !defaultAppGetInitialProps ? true : undefined, // whether the _app 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
|
element: React.ReactElement
|
||||||
): Promise<NodeWritablePiper> {
|
): Promise<NodeWritablePiper> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let reader: any = null
|
|
||||||
let resolved = false
|
let resolved = false
|
||||||
|
let underlyingStream: {
|
||||||
|
write: (s: string) => void
|
||||||
|
next: (err?: Error) => void
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const doResolve = () => {
|
const doResolve = () => {
|
||||||
if (resolved) return
|
resolve((res, next) => {
|
||||||
resolved = true
|
underlyingStream = {
|
||||||
const piper: NodeWritablePiper = (res, next) => {
|
write: res.write,
|
||||||
const streamReader: ReadableStreamDefaultReader = reader
|
next,
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
process()
|
})
|
||||||
}
|
|
||||||
resolve(piper)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const readable = (ReactDOMServer as any).renderToReadableStream(element, {
|
const { startWriting } = connectReactServerReadableStreamToPiper(
|
||||||
onError(err: Error) {
|
(s: string) => {
|
||||||
if (!resolved) {
|
if (!underlyingStream) {
|
||||||
resolved = true
|
throw new Error(
|
||||||
reject(err)
|
'invariant: `write` called without an underlying stream. This is a bug in Next.js'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
underlyingStream.write(s)
|
||||||
},
|
},
|
||||||
onCompleteShell() {
|
(err) => {
|
||||||
doResolve()
|
if (!underlyingStream) {
|
||||||
},
|
throw new Error(
|
||||||
})
|
'invariant: `next` called without an underlying stream. This is a bug in Next.js'
|
||||||
// Start reader and lock stream immediately to consume readable,
|
)
|
||||||
// Otherwise the bytes before `onCompleteShell` will be missed.
|
}
|
||||||
reader = readable.getReader()
|
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 */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
declare module 'next/dist/compiled/babel/plugin-transform-modules-commonjs'
|
declare module 'next/dist/compiled/babel/plugin-transform-modules-commonjs'
|
||||||
declare module 'next/dist/compiled/babel/plugin-syntax-jsx'
|
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 'browserslist'
|
||||||
|
|
||||||
declare module 'cssnano-simple' {
|
declare module 'cssnano-simple' {
|
||||||
|
|
Loading…
Reference in a new issue