diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 20c86444faa5a..e4b9909013dd6 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -2,7 +2,6 @@ import ReactRefreshWebpackPlugin from 'next/dist/compiled/@next/react-refresh-ut import chalk from 'next/dist/compiled/chalk' import crypto from 'crypto' import { stringify } from 'querystring' -import semver from 'next/dist/compiled/semver' import { webpack } from 'next/dist/compiled/webpack/webpack' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' import path, { join as pathJoin, relative as relativePath } from 'path' @@ -15,7 +14,6 @@ import { MIDDLEWARE_ROUTE, } from '../lib/constants' import { fileExists } from '../lib/file-exists' -import { getPackageVersion } from '../lib/get-package-version' import { CustomRoutes } from '../lib/load-custom-routes.js' import { CLIENT_STATIC_FILES_RUNTIME_AMP, @@ -52,6 +50,7 @@ import type { Span } from '../trace' import { getRawPageExtensions } from './utils' import browserslist from 'next/dist/compiled/browserslist' import loadJsConfig from './load-jsconfig' +import { shouldUseReactRoot } from '../server/config' const watchOptions = Object.freeze({ aggregateTimeout: 5, @@ -337,21 +336,7 @@ export default async function getBaseWebpackConfig( rewrites.afterFiles.length > 0 || rewrites.fallback.length > 0 const hasReactRefresh: boolean = dev && !isServer - const reactDomVersion = await getPackageVersion({ - cwd: dir, - name: 'react-dom', - }) - const isReactExperimental = Boolean( - reactDomVersion && /0\.0\.0-experimental/.test(reactDomVersion) - ) - const hasReact18: boolean = - Boolean(reactDomVersion) && - (semver.gte(reactDomVersion!, '18.0.0') || - semver.coerce(reactDomVersion)?.version === '18.0.0') - - const hasReactRoot: boolean = - config.experimental.reactRoot || hasReact18 || isReactExperimental - + const hasReactRoot = shouldUseReactRoot() const runtime = config.experimental.runtime // Make sure reactRoot is enabled when react 18 is detected @@ -360,11 +345,7 @@ export default async function getBaseWebpackConfig( } // Only inform during one of the builds - if ( - !isServer && - config.experimental.reactRoot && - !(hasReact18 || isReactExperimental) - ) { + if (!isServer && config.experimental.reactRoot && !hasReactRoot) { // 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`.') } diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts index e4835c150a15e..beefd6dcb8614 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts @@ -72,6 +72,7 @@ export function getRender({ webServerConfig: { extendRenderOpts: { buildId, + reactRoot: true, runtime: 'edge', supportsDynamicHTML: true, disableOptimizedLoading: true, diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 04a1727eddf2d..5569927609806 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -1,9 +1,10 @@ -import chalk from '../lib/chalk' -import findUp from 'next/dist/compiled/find-up' import { basename, extname, relative, isAbsolute, resolve } from 'path' import { pathToFileURL } from 'url' import { Agent as HttpAgent } from 'http' import { Agent as HttpsAgent } from 'https' +import semver from 'next/dist/compiled/semver' +import findUp from 'next/dist/compiled/find-up' +import chalk from '../lib/chalk' import * as Log from '../build/output/log' import { CONFIG_FILES, PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' import { execOnce } from '../shared/lib/utils' @@ -653,6 +654,11 @@ export default async function loadConfig( ) } + const hasReactRoot = shouldUseReactRoot() + if (hasReactRoot) { + userConfig.experimental.reactRoot = true + } + if (userConfig.amp?.canonicalBase) { const { canonicalBase } = userConfig.amp || ({} as any) userConfig.amp = userConfig.amp || {} @@ -698,6 +704,19 @@ export default async function loadConfig( return completeConfig } +export function shouldUseReactRoot() { + const reactDomVersion = require('react-dom').version + const isReactExperimental = Boolean( + reactDomVersion && /0\.0\.0-experimental/.test(reactDomVersion) + ) + const hasReact18: boolean = + Boolean(reactDomVersion) && + (semver.gte(reactDomVersion!, '18.0.0') || + semver.coerce(reactDomVersion)?.version === '18.0.0') + + return hasReact18 || isReactExperimental +} + export function setHttpAgentOptions( options: NextConfigComplete['httpAgentOptions'] ) { diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index e04b1668d19a6..faa55463fc651 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -940,7 +940,9 @@ export default class DevServer extends Server { // Build the error page to ensure the fallback is built too. // TODO: See if this can be moved into hotReloader or removed. await this.hotReloader!.ensurePage('/_error') - return await loadDefaultErrorComponents(this.distDir) + return await loadDefaultErrorComponents(this.distDir, { + hasConcurrentFeatures: !!this.renderOpts.runtime, + }) } protected setImmutableAssetCacheControl(res: BaseNextResponse): void { diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 28d41a6d571d7..e4c86fee8bcb3 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -34,8 +34,14 @@ export type LoadComponentsReturnType = { ComponentMod: any } -export async function loadDefaultErrorComponents(distDir: string) { - const Document = interopDefault(require('next/dist/pages/_document')) +export async function loadDefaultErrorComponents( + distDir: string, + { hasConcurrentFeatures }: { hasConcurrentFeatures: boolean } +) { + const Document = interopDefault( + require(`next/dist/pages/_document` + + (hasConcurrentFeatures ? '-concurrent' : '')) + ) const App = interopDefault(require('next/dist/pages/_app')) const ComponentMod = require('next/dist/pages/_error') const Component = interopDefault(ComponentMod) diff --git a/test/integration/react-18/test/index.test.js b/test/integration/react-18/test/index.test.js index 49c21b8c3ee01..3d85d9b65bac0 100644 --- a/test/integration/react-18/test/index.test.js +++ b/test/integration/react-18/test/index.test.js @@ -1,7 +1,6 @@ /* eslint-env jest */ import { join } from 'path' -import fs from 'fs-extra' import { File, @@ -57,12 +56,11 @@ async function getDevOutput(dir) { describe('React 18 Support', () => { describe('Use legacy render', () => { - beforeAll(async () => { - await fs.remove(join(appDir, 'node_modules')) + beforeAll(() => { nextConfig.replace('reactRoot: true', 'reactRoot: false') }) afterAll(() => { - nextConfig.replace('reactRoot: false', 'reactRoot: true') + nextConfig.restore() }) test('supported version of react in dev', async () => { @@ -75,15 +73,17 @@ describe('React 18 Support', () => { expect(output).not.toMatch(USING_CREATE_ROOT) }) - test('suspense is not allowed in blocking rendering mode (prod)', async () => { + test('suspense is not allowed in blocking rendering mode', async () => { + nextConfig.replace('withReact18({', '/*withReact18*/({') const { stderr, code } = await nextBuild(appDir, [], { - nodeArgs, stderr: true, }) - expect(code).toBe(1) + nextConfig.replace('/*withReact18*/({', 'withReact18({') + expect(stderr).toContain( 'Invalid suspense option usage in next/dynamic. Read more: https://nextjs.org/docs/messages/invalid-dynamic-suspense' ) + expect(code).toBe(1) }) }) }) @@ -119,56 +119,58 @@ describe('Blocking mode', () => { ) }) -describe('Concurrent mode in the edge runtime', () => { - beforeAll(async () => { - nextConfig.replace("// runtime: 'edge'", "runtime: 'edge'") - dynamicHello.replace('suspense = false', `suspense = true`) - // `noSSR` mode will be ignored by suspense - dynamicHello.replace('let ssr', `let ssr = false`) - }) - afterAll(async () => { - nextConfig.restore() - dynamicHello.restore() - }) +function runTestsAgainstRuntime(runtime) { + describe(`Concurrent mode in the ${runtime} runtime`, () => { + beforeAll(async () => { + nextConfig.replace("// runtime: 'edge'", `runtime: '${runtime}'`) + dynamicHello.replace('suspense = false', `suspense = true`) + // `noSSR` mode will be ignored by suspense + dynamicHello.replace('let ssr', `let ssr = false`) + }) + afterAll(async () => { + nextConfig.restore() + dynamicHello.restore() + }) - runTests('`runtime` is set to `edge`', (context) => { - concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q)) + runTests(`runtime is set to '${runtime}'`, (context) => { + concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q)) - it('should stream to users', async () => { - const res = await fetchViaHTTP(context.appPort, '/ssr') - expect(res.headers.get('etag')).toBeNull() - }) + it('should stream to users', async () => { + const res = await fetchViaHTTP(context.appPort, '/ssr') + expect(res.headers.get('etag')).toBeNull() + }) - it('should not stream to bots', async () => { - const res = await fetchViaHTTP( - context.appPort, - '/ssr', - {}, - { - headers: { - 'user-agent': 'Googlebot', - }, - } - ) - expect(res.headers.get('etag')).toBeDefined() - }) + it('should not stream to bots', async () => { + const res = await fetchViaHTTP( + context.appPort, + '/ssr', + {}, + { + headers: { + 'user-agent': 'Googlebot', + }, + } + ) + expect(res.headers.get('etag')).toBeDefined() + }) - it('should not stream to google pagerender bot', async () => { - const res = await fetchViaHTTP( - context.appPort, - '/ssr', - {}, - { - headers: { - 'user-agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 Google-PageRenderer Google (+https://developers.google.com/+/web/snippet/)', - }, - } - ) - expect(res.headers.get('etag')).toBeDefined() + it('should not stream to google pagerender bot', async () => { + const res = await fetchViaHTTP( + context.appPort, + '/ssr', + {}, + { + headers: { + 'user-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 Google-PageRenderer Google (+https://developers.google.com/+/web/snippet/)', + }, + } + ) + expect(res.headers.get('etag')).toBeDefined() + }) }) }) -}) +} function runTest(mode, name, fn) { const context = { appDir } @@ -200,6 +202,9 @@ function runTest(mode, name, fn) { }) } +runTestsAgainstRuntime('edge') +runTestsAgainstRuntime('nodejs') + function runTests(name, fn) { runTest('dev', name, fn) runTest('prod', name, fn) diff --git a/test/integration/react-18/test/with-react-18.js b/test/integration/react-18/test/with-react-18.js index 2f09e99d81509..f754eb801bc92 100644 --- a/test/integration/react-18/test/with-react-18.js +++ b/test/integration/react-18/test/with-react-18.js @@ -1,8 +1,4 @@ module.exports = function withReact18(config) { - if (typeof config.experimental.reactRoot === 'undefined') { - config.experimental.reactRoot = true - } - config.webpack = (webpackConfig) => { const { alias } = webpackConfig.resolve // FIXME: resolving react/jsx-runtime https://github.com/facebook/react/issues/20235 diff --git a/test/integration/react-streaming-and-server-components/app/next.config.js b/test/integration/react-streaming-and-server-components/app/next.config.js index 26829d37ca35a..ae1bd8cf8ebc6 100644 --- a/test/integration/react-streaming-and-server-components/app/next.config.js +++ b/test/integration/react-streaming-and-server-components/app/next.config.js @@ -6,7 +6,6 @@ module.exports = withReact18({ }, pageExtensions: ['js', 'ts', 'jsx'], // .tsx won't be treat as page, experimental: { - reactRoot: true, serverComponents: true, runtime: 'edge', },