From 459b391775ac32c326ebbfd7a27c89330b8d1405 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Tue, 10 Aug 2021 20:06:42 -0700 Subject: [PATCH] Add experimental `concurrentFeatures` config (#27768) Allows opting in to support for new concurrent features, like server-side Suspense. **!!! DO NOT USE !!!** This is highly experimental. We **will** be gating additional breaking changes behind this same flag. **!!! DO NOT USE !!!** Also resolves suspense for static pages (i.e. `getStaticProps` or `next build`/`next export`) since we can't currently support streaming for those cases anyway. --- packages/next/server/config-shared.ts | 2 + packages/next/server/next-server.ts | 2 + packages/next/server/render.tsx | 60 +++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index f8a7e7f41888..cbe5849346e5 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -113,6 +113,7 @@ export type NextConfig = { [key: string]: any } & { staticPageGenerationTimeout?: number pageDataCollectionTimeout?: number isrMemoryCacheSize?: number + concurrentFeatures?: boolean } } @@ -185,6 +186,7 @@ export const defaultConfig: NextConfig = { pageDataCollectionTimeout: 60, // default to 50MB limit isrMemoryCacheSize: 50 * 1024 * 1024, + concurrentFeatures: false, }, future: { strictPostcssConfiguration: false, diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index faaf13c23681..d62b6ded0d4b 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -181,6 +181,7 @@ export default class Server { defaultLocale?: string domainLocales?: DomainLocale[] distDir: string + concurrentFeatures?: boolean } private compression?: Middleware private incrementalCache: IncrementalCache @@ -241,6 +242,7 @@ export default class Server { .disableOptimizedLoading, domainLocales: this.nextConfig.i18n?.domains, distDir: this.distDir, + concurrentFeatures: this.nextConfig.experimental.concurrentFeatures, } // Only the `publicRuntimeConfig` key is exposed to the client side diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index a77e09592c8d..fdc515ed7321 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -1,7 +1,8 @@ import { IncomingMessage, ServerResponse } from 'http' import { ParsedUrlQuery } from 'querystring' +import { PassThrough } from 'stream' import React from 'react' -import { renderToStaticMarkup, renderToString } from 'react-dom/server' +import * as ReactDOMServer from 'react-dom/server' import { warn } from '../build/output/log' import { UnwrapPromise } from '../lib/coalesced-function' import { @@ -43,6 +44,7 @@ import { loadGetInitialProps, NextComponentType, RenderPage, + RenderPageResult, } from '../shared/lib/utils' import { tryGetPreviewData, @@ -190,6 +192,7 @@ export type RenderOptsPartial = { domainLocales?: DomainLocale[] disableOptimizedLoading?: boolean requireStaticHTML?: boolean + concurrentFeatures?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial @@ -263,7 +266,7 @@ function renderDocument( ): string { return ( '' + - renderToStaticMarkup( + ReactDOMServer.renderToStaticMarkup( {Document.renderDocument(Document, { __NEXT_DATA__: { @@ -408,6 +411,7 @@ export async function renderToHTML( previewProps, basePath, devOnlyCacheBusterQueryString, + concurrentFeatures, } = renderOpts const getFontDefinition = (url: string): string => { @@ -626,6 +630,8 @@ export async function renderToHTML( let head: JSX.Element[] = defaultHead(inAmpMode) let scriptLoader: any = {} + const nextExport = + !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) const AppContainer = ({ children }: any) => ( @@ -991,11 +997,45 @@ export async function renderToHTML( } } + // TODO: Support SSR streaming of Suspense. + const renderToString = concurrentFeatures + ? (element: React.ReactElement) => + new Promise((resolve, reject) => { + const stream = new PassThrough() + const buffers: Buffer[] = [] + stream.on('data', (chunk) => { + buffers.push(chunk) + }) + stream.once('end', () => { + resolve(Buffer.concat(buffers).toString('utf-8')) + }) + + const { + abort, + startWriting, + } = (ReactDOMServer as any).pipeToNodeWritable(element, stream, { + onError(error: Error) { + abort() + reject(error) + }, + onCompleteAll() { + startWriting() + }, + }) + }) + : ReactDOMServer.renderToString + const renderPage: RenderPage = ( options: ComponentsEnhancer = {} - ): { html: string; head: any } => { + ): RenderPageResult | Promise => { if (ctx.err && ErrorDebug) { - return { html: renderToString(), head } + const htmlOrPromise = renderToString() + return typeof htmlOrPromise === 'string' + ? { html: htmlOrPromise, head } + : htmlOrPromise.then((html) => ({ + html, + head, + })) } if (dev && (props.router || props.Component)) { @@ -1009,13 +1049,17 @@ export async function renderToHTML( Component: EnhancedComponent, } = enhanceComponents(options, App, Component) - const html = renderToString( + const htmlOrPromise = renderToString( ) - - return { html, head } + return typeof htmlOrPromise === 'string' + ? { html: htmlOrPromise, head } + : htmlOrPromise.then((html) => ({ + html, + head, + })) } const documentCtx = { ...ctx, renderPage } const docProps: DocumentInitialProps = await loadGetInitialProps( @@ -1049,8 +1093,6 @@ export async function renderToHTML( const hybridAmp = ampState.hybrid const docComponentsRendered: DocumentProps['docComponentsRendered'] = {} - const nextExport = - !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) let html = renderDocument(Document, { ...renderOpts,