Align reactRoot config between server and webpack config (#34328)

### Changes

* node server and webpack should share the same logic: auto detect react 18 and enable `reactRoot`
* fallback `_error` should use functional document if concurrent rendering is enabled

### Test

* Remove the hard code `reactRoot: true` in test suite
* Test some react-18 test suite with nodejs runtime
This commit is contained in:
Jiachi Liu 2022-02-14 17:18:57 +01:00 committed by GitHub
parent 7be6359aff
commit 1aee93581f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 93 additions and 84 deletions

View file

@ -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`.')
}

View file

@ -72,6 +72,7 @@ export function getRender({
webServerConfig: {
extendRenderOpts: {
buildId,
reactRoot: true,
runtime: 'edge',
supportsDynamicHTML: true,
disableOptimizedLoading: true,

View file

@ -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']
) {

View file

@ -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 {

View file

@ -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)

View file

@ -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()
})
runTests('`runtime` is set to `edge`', (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()
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()
})
it('should not stream to bots', async () => {
const res = await fetchViaHTTP(
context.appPort,
'/ssr',
{},
{
headers: {
'user-agent': 'Googlebot',
},
}
)
expect(res.headers.get('etag')).toBeDefined()
})
runTests(`runtime is set to '${runtime}'`, (context) => {
concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q))
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 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 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)

View file

@ -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

View file

@ -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',
},