From 622a1a554933b1bf7aca21af5248746c92747cc7 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Sat, 30 Oct 2021 23:35:31 +0200 Subject: [PATCH] Provide default fallback _document and _app for for concurrent mode (#30642) * if _app is not provided, fallback to default _app page * If _document is not provided, fallback to inline functional components version or use the default * if Document gIP is provided, error Closes #30654 --- packages/next/build/webpack-config.ts | 9 +- .../next-middleware-ssr-loader/index.ts | 75 ++++++++++-- .../react-rsc-basic/app/pages/_app.js | 7 -- .../react-rsc-basic/app/pages/_document.js | 13 -- .../react-rsc-basic/test/index.test.js | 113 +++++++++++++----- 5 files changed, 156 insertions(+), 61 deletions(-) delete mode 100644 test/integration/react-rsc-basic/app/pages/_app.js delete mode 100644 test/integration/react-rsc-basic/app/pages/_document.js diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index b3b3e394abd69..13b4d2ebc308e 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -361,11 +361,16 @@ export default async function getBaseWebpackConfig( const hasReactRoot: boolean = config.experimental.reactRoot || hasReact18 || isReactExperimental - if (config.experimental.reactRoot && !(hasReact18 || isReactExperimental)) { + // Only inform during one of the builds + if ( + !isServer && + config.experimental.reactRoot && + !(hasReact18 || isReactExperimental) + ) { // It's fine to only mention React 18 here as we don't recommend people to try experimental. Log.warn('You have to use React 18 to use `experimental.reactRoot`.') } - if (config.experimental.concurrentFeatures && !hasReactRoot) { + if (!isServer && config.experimental.concurrentFeatures && !hasReactRoot) { throw new Error( '`experimental.concurrentFeatures` requires `experimental.reactRoot` to be enabled along with React 18.' ) diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts index 562009149e308..0cfc7305c7339 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts @@ -1,7 +1,33 @@ import loaderUtils from 'next/dist/compiled/loader-utils' import { getStringifiedAbsolutePath } from './utils' -export default function middlewareRSCLoader(this: any) { +const fallbackDocumentPage = ` +import { Html, Head, Main, NextScript } from 'next/document' + +function Document() { + return ( + createElement(Html, null, + createElement(Head), + createElement('body', null, + createElement(Main), + createElement(NextScript), + ) + ) + ) +} +` + +function hasModule(path: string) { + let has + try { + has = !!require.resolve(path) + } catch (_) { + has = false + } + return has +} + +export default async function middlewareRSCLoader(this: any) { const { absolutePagePath, basePath, @@ -22,6 +48,21 @@ export default function middlewareRSCLoader(this: any) { './pages/_app' ) + const hasProvidedAppPage = hasModule(JSON.parse(stringifiedAbsoluteAppPath)) + const hasProvidedDocumentPage = hasModule( + JSON.parse(stringifiedAbsoluteDocumentPath) + ) + + let appDefinition = `const App = require(${ + hasProvidedAppPage + ? stringifiedAbsoluteAppPath + : JSON.stringify('next/dist/pages/_app') + }).default` + + let documentDefinition = hasProvidedDocumentPage + ? `const Document = require(${stringifiedAbsoluteDocumentPath}).default` + : fallbackDocumentPage + const transformed = ` import { adapter } from 'next/dist/server/web/adapter' @@ -38,30 +79,35 @@ export default function middlewareRSCLoader(this: any) { : '' } - var { + ${documentDefinition} + ${appDefinition} + + const { default: Page, config, getStaticProps, getServerSideProps, getStaticPaths } = require(${stringifiedAbsolutePagePath}) - var Document = require(${stringifiedAbsoluteDocumentPath}).default - var App = require(${stringifiedAbsoluteAppPath}).default const buildManifest = self.__BUILD_MANIFEST const reactLoadableManifest = self.__REACT_LOADABLE_MANIFEST const rscManifest = self._middleware_rsc_manifest if (typeof Page !== 'function') { - throw new Error('Your page must export a \`default\` component'); + throw new Error('Your page must export a \`default\` component') + } + + function renderError(err, status) { + return new Response(err.toString(), {status}) } - function wrapReadable (readable) { - var encoder = new TextEncoder() - var transformStream = new TransformStream() - var writer = transformStream.writable.getWriter() - var reader = readable.getReader() - var process = () => { + 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) @@ -82,7 +128,7 @@ export default function middlewareRSCLoader(this: any) { let responseCache const FlightWrapper = props => { - var response = responseCache + let response = responseCache if (!response) { responseCache = response = createFromReadableStream(renderFlight(props)) } @@ -103,6 +149,11 @@ export default function middlewareRSCLoader(this: any) { const url = request.nextUrl const query = Object.fromEntries(url.searchParams) + if (Document.getInitialProps) { + const err = new Error('Document.getInitialProps is not supported with server components, please remove it from pages/_document') + return renderError(err, 500) + } + // Preflight request if (request.method === 'HEAD') { return new Response('OK.', { diff --git a/test/integration/react-rsc-basic/app/pages/_app.js b/test/integration/react-rsc-basic/app/pages/_app.js deleted file mode 100644 index eee8821812081..0000000000000 --- a/test/integration/react-rsc-basic/app/pages/_app.js +++ /dev/null @@ -1,7 +0,0 @@ -import '../styles.css' - -function App({ Component, pageProps }) { - return -} - -export default App diff --git a/test/integration/react-rsc-basic/app/pages/_document.js b/test/integration/react-rsc-basic/app/pages/_document.js deleted file mode 100644 index bff2b1b2821cb..0000000000000 --- a/test/integration/react-rsc-basic/app/pages/_document.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Html, Head, Main, NextScript } from 'next/document' - -export default function Document() { - return ( - - - -
- - - - ) -} diff --git a/test/integration/react-rsc-basic/test/index.test.js b/test/integration/react-rsc-basic/test/index.test.js index 1a1d373818d6c..943af329657e7 100644 --- a/test/integration/react-rsc-basic/test/index.test.js +++ b/test/integration/react-rsc-basic/test/index.test.js @@ -5,6 +5,8 @@ import { join } from 'path' import fs from 'fs-extra' import { + File, + fetchViaHTTP, findPort, killApp, launchApp, @@ -18,6 +20,38 @@ import css from './css' const nodeArgs = ['-r', join(__dirname, '../../react-18/test/require-hook.js')] const appDir = join(__dirname, '../app') const distDir = join(__dirname, '../app/.next') +const documentPage = new File(join(appDir, 'pages/_document.js')) +const appPage = new File(join(appDir, 'pages/_app.js')) + +const documentWithGip = ` +import { Html, Head, Main, NextScript } from 'next/document' + +export default function Document() { + return ( + + + +
+ + + + ) +} + +Document.getInitialProps = (ctx) => { + return ctx.defaultGetInitialProps(ctx) +} +` + +const appWithGlobalCss = ` +import '../styles.css' + +function App({ Component, pageProps }) { + return +} + +export default App +` async function nextBuild(dir) { return await _nextBuild(dir, [], { @@ -99,7 +133,7 @@ describe('RSC prod', () => { expect(content.clientInfo).toContainEqual(item) } }) - runTests(context) + runBasicTests(context) }) describe('RSC dev', () => { @@ -112,39 +146,38 @@ describe('RSC dev', () => { afterAll(async () => { await killApp(context.server) }) - runTests(context) + runBasicTests(context) }) -describe('CSS prod', () => { - const context = { appDir } - - beforeAll(async () => { - context.appPort = await findPort() - await nextBuild(context.appDir) - context.server = await nextStart(context.appDir, context.appPort) - }) - afterAll(async () => { - await killApp(context.server) - }) +const cssSuite = { + runTests: css, + before: () => appPage.write(appWithGlobalCss), + after: () => appPage.delete(), +} - css(context) -}) +runSuite('CSS', 'dev', cssSuite) +runSuite('CSS', 'prod', cssSuite) -describe('CSS dev', () => { - const context = { appDir } +const documentSuite = { + runTests: (context) => { + it('should error when custom _document has getInitialProps method', async () => { + const res = await fetchViaHTTP(context.appPort, '/') + const html = await res.text() - beforeAll(async () => { - context.appPort = await findPort() - context.server = await nextDev(context.appDir, context.appPort) - }) - afterAll(async () => { - await killApp(context.server) - }) + expect(res.status).toBe(500) + expect(html).toContain( + 'Document.getInitialProps is not supported with server components, please remove it from pages/_document' + ) + }) + }, + before: () => documentPage.write(documentWithGip), + after: () => documentPage.delete(), +} - css(context) -}) +runSuite('document', 'dev', documentSuite) +runSuite('document', 'prod', documentSuite) -async function runTests(context) { +async function runBasicTests(context) { it('should render the correct html', async () => { const homeHTML = await renderViaHTTP(context.appPort, '/') @@ -181,3 +214,29 @@ async function runTests(context) { expect(imageTag.attr('src')).toContain('data:image') }) } + +function runSuite(suiteName, env, { runTests, before, after }) { + const context = { appDir } + describe(`${suiteName} ${env}`, () => { + if (env === 'prod') { + beforeAll(async () => { + before?.() + context.appPort = await findPort() + context.server = await nextDev(context.appDir, context.appPort) + }) + } + if (env === 'dev') { + beforeAll(async () => { + before?.() + context.appPort = await findPort() + context.server = await nextDev(context.appDir, context.appPort) + }) + } + afterAll(async () => { + after?.() + await killApp(context.server) + }) + + runTests(context) + }) +}