From b35cbbfd91b2da31ebfc82b9f2389855feecc421 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 23 Aug 2021 21:09:49 +0000 Subject: [PATCH] Support for functional Document components --- .../next-serverless-loader/page-handler.ts | 1 + packages/next/server/render.tsx | 435 ++++++++---------- .../react-18/app/pages/_document.js | 13 + 3 files changed, 206 insertions(+), 243 deletions(-) create mode 100644 test/integration/react-18/app/pages/_document.js diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts index db761f6871d3e..28c2db77b797a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts @@ -116,6 +116,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { previewProps: encodedPreviewProps, env: process.env, basePath, + requireStaticHTML: true, // Serverless target doesn't support streaming ..._renderOpts, } let _nextData = false diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 762a18b7c1c0a..2ae9a764e4574 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -3,6 +3,7 @@ import { ParsedUrlQuery } from 'querystring' import { PassThrough } from 'stream' import React from 'react' import * as ReactDOMServer from 'react-dom/server' +import flush from 'styled-jsx/server' import Observable from 'next/dist/compiled/zen-observable' import { warn } from '../build/output/log' import { UnwrapPromise } from '../lib/coalesced-function' @@ -39,8 +40,8 @@ import { ComponentsEnhancer, DocumentInitialProps, DocumentProps, - DocumentType, HtmlContext, + HtmlProps, getDisplayName, isResSent, loadGetInitialProps, @@ -195,131 +196,11 @@ export type RenderOptsPartial = { disableOptimizedLoading?: boolean requireStaticHTML?: boolean concurrentFeatures?: boolean + customServer?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial -function renderDocument( - Document: DocumentType, - { - buildManifest, - docComponentsRendered, - props, - docProps, - pathname, - query, - buildId, - canonicalBase, - assetPrefix, - runtimeConfig, - nextExport, - autoExport, - isFallback, - dynamicImportsIds, - dangerousAsPath, - err, - dev, - ampPath, - ampState, - inAmpMode, - hybridAmp, - dynamicImports, - headTags, - gsp, - gssp, - customServer, - gip, - appGip, - unstable_runtimeJS, - unstable_JsPreload, - devOnlyCacheBusterQueryString, - scriptLoader, - locale, - locales, - defaultLocale, - domainLocales, - isPreview, - disableOptimizedLoading, - }: RenderOpts & { - props: any - docComponentsRendered: DocumentProps['docComponentsRendered'] - docProps: DocumentInitialProps - pathname: string - query: ParsedUrlQuery - dangerousAsPath: string - ampState: any - ampPath: string - inAmpMode: boolean - hybridAmp: boolean - dynamicImportsIds: (string | number)[] - dynamicImports: string[] - headTags: any - isFallback?: boolean - gsp?: boolean - gssp?: boolean - customServer?: boolean - gip?: boolean - appGip?: boolean - devOnlyCacheBusterQueryString: string - scriptLoader: any - isPreview?: boolean - autoExport?: boolean - } -): string { - const htmlProps = { - __NEXT_DATA__: { - props, // The result of getInitialProps - page: pathname, // The rendered page - query, // querystring parsed / passed by the user - buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles - assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML - runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML - nextExport, // If this is a page exported by `next export` - autoExport, // If this is an auto exported page - isFallback, - dynamicIds: - dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds, - err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML - gsp, // whether the page is getStaticProps - gssp, // whether the page is getServerSideProps - customServer, // whether the user is using a custom server - gip, // whether the page has getInitialProps - appGip, // whether the _app has getInitialProps - locale, - locales, - defaultLocale, - domainLocales, - isPreview, - }, - buildManifest, - docComponentsRendered, - dangerousAsPath, - canonicalBase, - ampPath, - inAmpMode, - isDevelopment: !!dev, - hybridAmp, - dynamicImports, - assetPrefix, - headTags, - unstable_runtimeJS, - unstable_JsPreload, - devOnlyCacheBusterQueryString, - scriptLoader, - locale, - disableOptimizedLoading, - styles: docProps.styles, - head: docProps.head, - } - return ReactDOMServer.renderToStaticMarkup( - - - - - - ) -} - const invalidKeysMsg = (methodName: string, invalidKeys: string[]) => { return ( `Additional keys were returned from \`${methodName}\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` + @@ -637,7 +518,7 @@ export async function renderToHTML( const nextExport = !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) - const AppContainer = ({ children }: any) => ( + const AppContainer = ({ children }: { children: JSX.Element }) => ( { - const result = await renderToStream(element) - return await resultsToString([result]) - } - : ReactDOMServer.renderToString - - const renderPage: RenderPage = ( - options: ComponentsEnhancer = {} - ): RenderPageResult | Promise => { - if (ctx.err && ErrorDebug) { - const htmlOrPromise = renderToString() - return typeof htmlOrPromise === 'string' - ? { html: htmlOrPromise, head } - : htmlOrPromise.then((html) => ({ - html, - head, - })) - } + const renderDocument = async () => { + if (Document.getInitialProps) { + const renderPage: RenderPage = ( + options: ComponentsEnhancer = {} + ): RenderPageResult | Promise => { + if (ctx.err && ErrorDebug) { + const html = ReactDOMServer.renderToString( + + ) + return { html, head } + } - if (dev && (props.router || props.Component)) { - throw new Error( - `'router' and 'Component' can not be returned in getInitialProps from _app.js https://nextjs.org/docs/messages/cant-override-next-props` - ) - } + if (dev && (props.router || props.Component)) { + throw new Error( + `'router' and 'Component' can not be returned in getInitialProps from _app.js https://nextjs.org/docs/messages/cant-override-next-props` + ) + } - const { App: EnhancedApp, Component: EnhancedComponent } = - enhanceComponents(options, App, Component) + const { App: EnhancedApp, Component: EnhancedComponent } = + enhanceComponents(options, App, Component) + + const html = ReactDOMServer.renderToString( + + + + ) + return { html, head } + } + const documentCtx = { ...ctx, renderPage } + const docProps: DocumentInitialProps = await loadGetInitialProps( + Document, + documentCtx + ) + // the response might be finished on the getInitialProps call + if (isResSent(res) && !isSSG) return null + + if (!docProps || typeof docProps.html !== 'string') { + const message = `"${getDisplayName( + Document + )}.getInitialProps()" should resolve to an object with a "html" prop set with a valid html string` + throw new Error(message) + } - const htmlOrPromise = renderToString( - - - - ) - return typeof htmlOrPromise === 'string' - ? { html: htmlOrPromise, head } - : htmlOrPromise.then((html) => ({ - html, - head, - })) + return { + bodyResult: Observable.of(docProps.html), + documentElement: (htmlProps: HtmlProps) => ( + + ), + head: docProps.head, + headTags: await headTags(documentCtx), + styles: docProps.styles, + } + } else { + const content = + ctx.err && ErrorDebug ? ( + + ) : ( + + + + ) + const bodyResult = concurrentFeatures + ? await renderToStream(content) + : Observable.of(ReactDOMServer.renderToString(content)) + + return { + bodyResult, + documentElement: () => (Document as any)(), + head, + headTags: [], + // TODO: Experimental styled-jsx 5 support + styles: [...flush()], + } + } } - const documentCtx = { ...ctx, renderPage } - const docProps: DocumentInitialProps = await loadGetInitialProps( - Document, - documentCtx - ) - // the response might be finished on the getInitialProps call - if (isResSent(res) && !isSSG) return null - if (!docProps || typeof docProps.html !== 'string') { - const message = `"${getDisplayName( - Document - )}.getInitialProps()" should resolve to an object with a "html" prop set with a valid html string` - throw new Error(message) + const documentResult = await renderDocument() + if (!documentResult) { + return null } const dynamicImportsIds = new Set() @@ -1122,44 +1032,78 @@ export async function renderToHTML( const hybridAmp = ampState.hybrid const docComponentsRendered: DocumentProps['docComponentsRendered'] = {} - - const documentHTML = renderDocument(Document, { - ...renderOpts, + const { + assetPrefix, + buildId, + customServer, + defaultLocale, + disableOptimizedLoading, + domainLocales, + locale, + locales, + runtimeConfig, + } = renderOpts + const htmlProps: any = { + __NEXT_DATA__: { + props, // The result of getInitialProps + page: pathname, // The rendered page + query, // querystring parsed / passed by the user + buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles + assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML + runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML + nextExport: nextExport === true ? true : undefined, // If this is a page exported by `next export` + autoExport: isAutoExport === true ? true : undefined, // If this is an auto exported page + isFallback, + dynamicIds: + dynamicImportsIds.size === 0 + ? undefined + : Array.from(dynamicImportsIds), + 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 + 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 + locale, + locales, + defaultLocale, + domainLocales, + isPreview: isPreview === true ? true : undefined, + }, + buildManifest: filteredBuildManifest, + docComponentsRendered, + dangerousAsPath: router.asPath, canonicalBase: !renderOpts.ampPath && (req as any).__nextStrippedLocale ? `${renderOpts.canonicalBase || ''}/${renderOpts.locale}` : renderOpts.canonicalBase, - docComponentsRendered, - buildManifest: filteredBuildManifest, + ampPath, + inAmpMode, + isDevelopment: !!dev, + hybridAmp, + dynamicImports: Array.from(dynamicImports), + assetPrefix, // Only enabled in production as development mode has features relying on HMR (style injection for example) unstable_runtimeJS: process.env.NODE_ENV === 'production' ? pageConfig.unstable_runtimeJS : undefined, unstable_JsPreload: pageConfig.unstable_JsPreload, - dangerousAsPath: router.asPath, - ampState, - props, - headTags: await headTags(documentCtx), - isFallback, - docProps, - pathname, - ampPath, - query, - inAmpMode, - hybridAmp, - dynamicImportsIds: Array.from(dynamicImportsIds), - dynamicImports: Array.from(dynamicImports), - gsp: !!getStaticProps ? true : undefined, - gssp: !!getServerSideProps ? true : undefined, - gip: hasPageGetInitialProps ? true : undefined, - appGip: !defaultAppGetInitialProps ? true : undefined, devOnlyCacheBusterQueryString, scriptLoader, - isPreview: isPreview === true ? true : undefined, - autoExport: isAutoExport === true ? true : undefined, - nextExport: nextExport === true ? true : undefined, - }) + locale, + disableOptimizedLoading, + head: documentResult.head, + headTags: documentResult?.headTags, + styles: documentResult.styles, + } + const documentHTML = ReactDOMServer.renderToStaticMarkup( + + + {documentResult.documentElement(htmlProps)} + + + ) if (process.env.NODE_ENV !== 'production') { const nonRenderedComponents = [] @@ -1194,57 +1138,62 @@ export async function renderToHTML( if (inAmpMode) { results.push(Observable.of('')) } - results.push(Observable.of(docProps.html)) + results.push(documentResult.bodyResult) results.push( Observable.of( documentHTML.substring(renderTargetIdx + BODY_RENDER_TARGET.length) ) ) - const postProcessors: Array<((html: string) => Promise) | null> = [ - inAmpMode - ? async (html: string) => { - html = await optimizeAmp(html, renderOpts.ampOptimizerConfig) - if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) { - await renderOpts.ampValidator(html, pathname) - } - return html - } - : null, - process.env.__NEXT_OPTIMIZE_FONTS || process.env.__NEXT_OPTIMIZE_IMAGES - ? async (html: string) => { - return await postProcess( - html, - { getFontDefinition }, - { - optimizeFonts: renderOpts.optimizeFonts, - optimizeImages: renderOpts.optimizeImages, - } - ) - } - : null, - renderOpts.optimizeCss - ? async (html: string) => { - // eslint-disable-next-line import/no-extraneous-dependencies - const Critters = require('critters') - const cssOptimizer = new Critters({ - ssrMode: true, - reduceInlineStyles: false, - path: renderOpts.distDir, - publicPath: `${renderOpts.assetPrefix}/_next/`, - preload: 'media', - fonts: false, - ...renderOpts.optimizeCss, - }) - return await cssOptimizer.process(html) - } - : null, - inAmpMode || hybridAmp - ? async (html: string) => { - return html.replace(/&amp=1/g, '&=1') - } - : null, - ].filter(Boolean) + const postProcessors: Array<((html: string) => Promise) | null> = ( + generateStaticHTML + ? [ + inAmpMode + ? async (html: string) => { + html = await optimizeAmp(html, renderOpts.ampOptimizerConfig) + if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) { + await renderOpts.ampValidator(html, pathname) + } + return html + } + : null, + process.env.__NEXT_OPTIMIZE_FONTS || + process.env.__NEXT_OPTIMIZE_IMAGES + ? async (html: string) => { + return await postProcess( + html, + { getFontDefinition }, + { + optimizeFonts: renderOpts.optimizeFonts, + optimizeImages: renderOpts.optimizeImages, + } + ) + } + : null, + renderOpts.optimizeCss + ? async (html: string) => { + // eslint-disable-next-line import/no-extraneous-dependencies + const Critters = require('critters') + const cssOptimizer = new Critters({ + ssrMode: true, + reduceInlineStyles: false, + path: renderOpts.distDir, + publicPath: `${renderOpts.assetPrefix}/_next/`, + preload: 'media', + fonts: false, + ...renderOpts.optimizeCss, + }) + return await cssOptimizer.process(html) + } + : null, + inAmpMode || hybridAmp + ? async (html: string) => { + return html.replace(/&amp=1/g, '&=1') + } + : null, + ] + : [] + ).filter(Boolean) if (postProcessors.length > 0) { let html = await resultsToString(results) diff --git a/test/integration/react-18/app/pages/_document.js b/test/integration/react-18/app/pages/_document.js new file mode 100644 index 0000000000000..bff2b1b2821cb --- /dev/null +++ b/test/integration/react-18/app/pages/_document.js @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from 'next/document' + +export default function Document() { + return ( + + + +
+ + + + ) +}