mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
3c4afc1a3a
This moves the dev-mode Cache Components validation renders off the dev server's main thread onto a worker thread, so rapid navigation no longer starves the event loop. The worker crosses into the app-page bundle exactly once, calling a new `ComponentMod.routeModule.runValidationInDev` entry that rebuilds the render context, work store, and request store from a serializable snapshot and runs the whole validation there, so the client prerender and the user's client components resolve the single app-page React instance rather than a second copy. A thin worker shell (`dev-validation-worker.ts`) plus a single-worker pool (`dev-validation-worker-pool.ts`) load the user bundle via `loadComponents`, install code-frame support for CLI output, and forward the validation errors back as Flight bytes; the main thread only delivers them to the overlay through `sendErrorsToBrowser`. The snapshot, the globalThis-symbol handoff, and the shared error-delivery helpers live in their own modules. The dev server installs the worker when `experimental.devValidationWorker` is not `false`, and `runDevValidationInBackground` uses it when present, falling back to the in-process path otherwise. A one-slot `SharedArrayBuffer` propagates a supersede abort into the worker so a newer navigation cancels an in-flight validation. The runtime bundle gains an `app-worker` entry for the worker with the `build/swc` boundary externalized for the code-frame native binding, and `patch-error-inspect.ts` now backs its code-frame renderer with a globalThis symbol so all copies of the module share it across the thread. The synthetic `bench/dev-validation` benchmark navigates back-to-back, so every click lands inside the validation window — the worst case for main-thread contention. Browser-observed navigation TTFB in that window, worker vs in-process: | Route | Worker (p50/p95/max) | In-process (p50/p95/max) | | ------ | -------------------- | ------------------------ | | client | 19 / 24 / 27 ms | 40 / 66 / 7762 ms | | server | 42 / 45 / 46 ms | 122 / 158 / 252 ms | | sprite | 109 / 117 / 169 ms | 196 / 208 / 299 ms | The steady-state difference is only tens of milliseconds; the effect that matters is the tail. In-process, a navigation that collides with an in-flight validation render can stall for seconds (this run peaked at ~7.8s on the client route, and the peak varies run to run) because a staged render does not yield until it finishes; off-thread the main thread stays free and that stall disappears. Read these as an upper bound, not a speedup that generalizes. The work moved off-thread is the validation render's CPU — bounded, route-dependent, and free of IO — so it does not grow with the main render's cost. In an app whose main render is dominated by IO the same absolute saving is a small fraction of the request, and it only appears when a navigation lands in the brief validation window; at ordinary click speed it is largely invisible. This is a dev-only responsiveness improvement whose benefit varies widely with the app and the navigation pattern. As a follow-up, the validation work that still runs on the main thread could move to the worker as well. When the main render can't be reused for validation (for example after a cache miss), the validation re-renders on the main thread to produce its inputs, resuming from the Resume Data Cache (RDC) the main render already filled, and only then hands the resulting Flight chunks to the worker. A later iteration could run those renders inside the worker too, transporting the (serializable) RDC so the worker can resume from the filled caches rather than reading them back on the main thread. closes NAR-895
372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
const webpack = require('@rspack/core')
|
|
const path = require('path')
|
|
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
|
|
const DevToolsIgnoreListPlugin = require('./webpack-plugins/devtools-ignore-list-plugin')
|
|
|
|
function shouldIgnorePath(modulePath) {
|
|
// For consumers, everything will be considered 3rd party dependency if they use
|
|
// the bundles we produce here.
|
|
// In other words, this is all library code and should therefore be ignored.
|
|
return true
|
|
}
|
|
|
|
const pagesExternals = [
|
|
'react',
|
|
'react/package.json',
|
|
'react/jsx-runtime',
|
|
'react/jsx-dev-runtime',
|
|
'react/compiler-runtime',
|
|
'react-dom',
|
|
'react-dom/package.json',
|
|
'react-dom/client',
|
|
'react-dom/server',
|
|
'react-dom/server.browser',
|
|
'react-dom/server.edge',
|
|
'react-server-dom-webpack/client',
|
|
'react-server-dom-webpack/server',
|
|
'react-server-dom-webpack/server.node',
|
|
'react-server-dom-webpack/static',
|
|
]
|
|
|
|
const appExternals = []
|
|
|
|
function makeAppAliases({ experimental, bundler }) {
|
|
const reactChannel = experimental ? '-experimental' : ''
|
|
|
|
return {
|
|
react$: `next/dist/compiled/react${reactChannel}`,
|
|
'react/react.react-server$': `next/dist/compiled/react${reactChannel}/react.react-server`,
|
|
'react-dom$': `next/dist/compiled/react-dom${reactChannel}`,
|
|
'react/jsx-runtime$': `next/dist/compiled/react${reactChannel}/jsx-runtime`,
|
|
'react/jsx-dev-runtime$': `next/dist/compiled/react${reactChannel}/jsx-dev-runtime`,
|
|
'react/compiler-runtime$': `next/dist/compiled/react${reactChannel}/compiler-runtime`,
|
|
'react-dom/client$': `next/dist/compiled/react-dom${reactChannel}/client`,
|
|
// optimizations to ignore the legacy APIs in react-dom/server
|
|
'react-dom/server$': `next/dist/build/webpack/alias/react-dom-server${reactChannel}.js`,
|
|
'react-dom/static$': `next/dist/compiled/react-dom${reactChannel}/static.node`,
|
|
// react-server-dom-webpack alias
|
|
'react-server-dom-turbopack/client$': `next/dist/compiled/react-server-dom-turbopack${reactChannel}/client.node`,
|
|
'react-server-dom-turbopack/server$': `next/dist/compiled/react-server-dom-turbopack${reactChannel}/server.node`,
|
|
'react-server-dom-turbopack/server.node$': `next/dist/compiled/react-server-dom-turbopack${reactChannel}/server.node`,
|
|
'react-server-dom-turbopack/static$': `next/dist/compiled/react-server-dom-turbopack${reactChannel}/static.node`,
|
|
'react-server-dom-webpack/client$': `next/dist/compiled/react-server-dom-${bundler}${reactChannel}/client.node`,
|
|
'react-server-dom-webpack/server$': `next/dist/compiled/react-server-dom-${bundler}${reactChannel}/server.node`,
|
|
'react-server-dom-webpack/server.node$': `next/dist/compiled/react-server-dom-${bundler}${reactChannel}/server.node`,
|
|
'react-server-dom-webpack/static$': `next/dist/compiled/react-server-dom-${bundler}${reactChannel}/static.node`,
|
|
'@vercel/turbopack-ecmascript-runtime/browser/dev/hmr-client/hmr-client.ts':
|
|
'next/dist/client/dev/noop-turbopack-hmr',
|
|
}
|
|
}
|
|
|
|
const sharedExternals = [
|
|
'styled-jsx',
|
|
'styled-jsx/style',
|
|
'@opentelemetry/api',
|
|
'next/dist/compiled/@ampproject/toolbox-optimizer',
|
|
'next/dist/compiled/edge-runtime',
|
|
'next/dist/compiled/@edge-runtime/ponyfill',
|
|
'next/dist/compiled/undici',
|
|
'next/dist/compiled/raw-body',
|
|
'next/dist/server/capsize-font-metrics.json',
|
|
'critters',
|
|
'next/dist/compiled/node-html-parser',
|
|
'next/dist/compiled/compression',
|
|
'next/dist/compiled/jsonwebtoken',
|
|
'next/dist/compiled/@opentelemetry/api',
|
|
'next/dist/compiled/@mswjs/interceptors/ClientRequest',
|
|
'next/dist/compiled/ws',
|
|
]
|
|
|
|
const externalsMap = {
|
|
'./web/sandbox': 'next/dist/server/web/sandbox',
|
|
'next/dist/compiled/next-devtools':
|
|
'commonjs next/dist/next-devtools/dev-overlay.shim.js',
|
|
}
|
|
|
|
const externalsRegexMap = {
|
|
'(.*)trace/tracer$': 'next/dist/server/lib/trace/tracer',
|
|
}
|
|
|
|
const bundleTypes = {
|
|
app: {
|
|
'app-page': path.join(
|
|
__dirname,
|
|
'dist/esm/server/route-modules/app-page/module.js'
|
|
),
|
|
'app-route': path.join(
|
|
__dirname,
|
|
'dist/esm/server/route-modules/app-route/module.js'
|
|
),
|
|
},
|
|
pages: {
|
|
pages: path.join(
|
|
__dirname,
|
|
'dist/esm/server/route-modules/pages/module.js'
|
|
),
|
|
'pages-api': path.join(
|
|
__dirname,
|
|
'dist/esm/server/route-modules/pages-api/module.js'
|
|
),
|
|
},
|
|
server: {
|
|
server: path.join(__dirname, 'dist/esm/server/next-server.js'),
|
|
},
|
|
'app-worker': {
|
|
'use-cache-probe-worker': path.join(
|
|
__dirname,
|
|
'dist/esm/server/dev/use-cache-probe-worker.js'
|
|
),
|
|
'dev-validation-worker': path.join(
|
|
__dirname,
|
|
'dist/esm/server/dev/dev-validation-worker.js'
|
|
),
|
|
},
|
|
}
|
|
|
|
/**
|
|
* @param {Object} options
|
|
* @param {boolean} options.dev
|
|
* @param {boolean} options.turbo
|
|
* @param {keyof typeof bundleTypes} options.bundleType
|
|
* @param {boolean} options.experimental
|
|
* @param {Partial<webpack.Configuration>} options.rest
|
|
* @returns {webpack.Configuration}
|
|
*/
|
|
module.exports = ({ dev, turbo, bundleType, experimental, ...rest }) => {
|
|
const externalHandler = ({ context, request, getResolve }, callback) => {
|
|
;(async () => {
|
|
if (
|
|
request.match(
|
|
/next[/\\]dist[/\\]compiled[/\\](babel|webpack|source-map|semver|jest-worker|stacktrace-parser|@ampproject\/toolbox-optimizer)/
|
|
)
|
|
) {
|
|
callback(null, 'commonjs ' + request)
|
|
return
|
|
}
|
|
|
|
if (request.match(/(server\/image-optimizer|experimental\/testmode)/)) {
|
|
callback(null, 'commonjs ' + request)
|
|
return
|
|
}
|
|
|
|
// The dev validation worker calls `installBindings()` /
|
|
// `installCodeFrameSupport()` so its logged errors render source-mapped
|
|
// code frames. Those pull in the native SWC binding graph (`build/swc` →
|
|
// `trace` → `telemetry`), which isn't present under `dist/esm`. Resolve
|
|
// it from the installed `next/dist` tree at runtime instead of bundling
|
|
// it into the worker, exactly as the unbundled build worker loads it.
|
|
// Scoped to the `app-worker` bundle; the probe worker in the same group
|
|
// doesn't import it.
|
|
if (
|
|
bundleType === 'app-worker' &&
|
|
request.match(/[\\/]build[\\/]swc(?:[\\/]install-bindings)?(?:\.js)?$/)
|
|
) {
|
|
const resolve = getResolve()
|
|
const resolved = await resolve(context, request)
|
|
const relative = path.relative(
|
|
path.join(__dirname, '..'),
|
|
resolved.replace('esm' + path.sep, '')
|
|
)
|
|
callback(null, `commonjs ${relative}`)
|
|
return
|
|
}
|
|
|
|
if (request.match(/\.external(\.js)?$/)) {
|
|
const resolve = getResolve()
|
|
const resolved = await resolve(context, request)
|
|
const relative = path.relative(
|
|
path.join(__dirname, '..'),
|
|
resolved.replace('esm' + path.sep, '')
|
|
)
|
|
callback(null, `commonjs ${relative}`)
|
|
} else {
|
|
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
|
|
new RegExp(regex).test(request)
|
|
)
|
|
if (regexMatch) {
|
|
return callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
|
|
}
|
|
callback()
|
|
}
|
|
})()
|
|
}
|
|
|
|
const bundledReactChannel = experimental ? '-experimental' : ''
|
|
|
|
const alias =
|
|
bundleType === 'app' || bundleType === 'app-worker'
|
|
? makeAppAliases({
|
|
experimental,
|
|
bundler: turbo ? 'turbopack' : 'webpack',
|
|
})
|
|
: {}
|
|
|
|
return {
|
|
entry: bundleTypes[bundleType],
|
|
target: 'node',
|
|
mode:
|
|
process.env.NEXT_DEBUG_INTERNALS === 'true'
|
|
? 'development'
|
|
: dev
|
|
? 'development'
|
|
: 'production',
|
|
output: {
|
|
path: path.join(__dirname, 'dist/compiled/next-server'),
|
|
filename: `[name]${turbo ? '-turbo' : ''}${
|
|
experimental ? '-experimental' : ''
|
|
}.runtime.${dev ? 'dev' : 'prod'}.js`,
|
|
library: {
|
|
type: 'commonjs2',
|
|
},
|
|
},
|
|
devtool: 'source-map',
|
|
optimization:
|
|
process.env.NEXT_DEBUG_INTERNALS === 'true'
|
|
? undefined
|
|
: {
|
|
moduleIds: 'named',
|
|
minimize: true,
|
|
concatenateModules: true,
|
|
minimizer: [
|
|
new webpack.SwcJsMinimizerRspackPlugin({
|
|
minimizerOptions: {
|
|
compress: {
|
|
defaults: true,
|
|
// FIXME: compiler bug: wrongly merging two conditionals with different return values into one
|
|
// (in `prepareValidationInputsInPartialPrefetching`)
|
|
conditionals: false,
|
|
},
|
|
mangle:
|
|
dev || process.env.NEXT_SERVER_NO_MANGLE ? false : true,
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
plugins: [
|
|
new DevToolsIgnoreListPlugin({ shouldIgnorePath }),
|
|
new webpack.DefinePlugin({
|
|
'typeof window': JSON.stringify('undefined'),
|
|
'process.env.NEXT_MINIMAL': JSON.stringify('true'),
|
|
'this.serverOptions.experimentalTestProxy': JSON.stringify(false),
|
|
'this.minimalMode': JSON.stringify(true),
|
|
// Only inline __NEXT_DEV_SERVER in prod bundles (for dead-code
|
|
// elimination). Dev bundles must keep it as a runtime check because
|
|
// they're shared between `next dev` (where it's set) and `next build
|
|
// --debug-prerender` (where it's not).
|
|
...(dev ? {} : { 'process.env.__NEXT_DEV_SERVER': JSON.stringify('') }),
|
|
'process.env.NODE_ENV': JSON.stringify(
|
|
dev ? 'development' : 'production'
|
|
),
|
|
'process.env.__NEXT_EXPERIMENTAL_REACT': JSON.stringify(
|
|
experimental ? true : false
|
|
),
|
|
...(bundleType === 'app' || bundleType === 'app-worker'
|
|
? { 'process.env.__NEXT_USE_NODE_STREAMS': JSON.stringify(true) }
|
|
: {}),
|
|
'process.env.NEXT_RUNTIME': JSON.stringify('nodejs'),
|
|
'process.turbopack': JSON.stringify(turbo),
|
|
'process.env.TURBOPACK': JSON.stringify(turbo),
|
|
}),
|
|
!!process.env.ANALYZE &&
|
|
new BundleAnalyzerPlugin({
|
|
analyzerPort: calculateUniquePort(
|
|
dev,
|
|
turbo,
|
|
experimental,
|
|
bundleType
|
|
),
|
|
openAnalyzer: false,
|
|
...(process.env.CI
|
|
? {
|
|
analyzerMode: 'static',
|
|
reportFilename: path.join(
|
|
__dirname,
|
|
`dist/compiled/next-server/report.${dev ? 'dev' : 'prod'}-${
|
|
turbo ? 'turbo' : 'webpack'
|
|
}-${
|
|
experimental ? 'experimental' : 'stable'
|
|
}-${bundleType}.html`
|
|
),
|
|
}
|
|
: {}),
|
|
}),
|
|
].filter(Boolean),
|
|
stats: {
|
|
optimizationBailout: true,
|
|
},
|
|
resolve: {
|
|
alias,
|
|
},
|
|
module: {
|
|
rules: [
|
|
{ test: /\.m?js$/, loader: `source-map-loader`, enforce: `pre` },
|
|
{
|
|
include: [
|
|
/[\\/]react-server\.node/,
|
|
/server[\\/]dev[\\/]use-cache-probe-worker/,
|
|
],
|
|
layer: 'react-server',
|
|
},
|
|
{
|
|
include: /vendored[\\/]rsc[\\/]entrypoints/,
|
|
resolve: {
|
|
conditionNames: ['react-server', '...'],
|
|
alias: {
|
|
react$: `next/dist/compiled/react${bundledReactChannel}/react.react-server`,
|
|
[`next/dist/compiled/react${bundledReactChannel}$`]: `next/dist/compiled/react${bundledReactChannel}/react.react-server`,
|
|
'react/jsx-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-runtime.react-server`,
|
|
[`next/dist/compiled/react${bundledReactChannel}/jsx-runtime$`]: `next/dist/compiled/react${bundledReactChannel}/jsx-runtime.react-server`,
|
|
'react/jsx-dev-runtime$': `next/dist/compiled/react${bundledReactChannel}/jsx-dev-runtime.react-server`,
|
|
[`next/dist/compiled/react${bundledReactChannel}/jsx-dev-runtime$`]: `next/dist/compiled/react${bundledReactChannel}/jsx-dev-runtime.react-server`,
|
|
'react/compiler-runtime$': `next/dist/compiled/react${bundledReactChannel}/compiler-runtime`,
|
|
[`next/dist/compiled/react${bundledReactChannel}/compiler-runtime$`]: `next/dist/compiled/react${bundledReactChannel}/compiler-runtime`,
|
|
'react-dom$': `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`,
|
|
[`next/dist/compiled/react-dom${bundledReactChannel}$`]: `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`,
|
|
},
|
|
},
|
|
layer: 'react-server',
|
|
},
|
|
{
|
|
issuerLayer: 'react-server',
|
|
resolve: {
|
|
conditionNames: ['react-server', '...'],
|
|
alias: {
|
|
react$: `next/dist/compiled/react${bundledReactChannel}/react.react-server`,
|
|
[`next/dist/compiled/react${bundledReactChannel}$`]: `next/dist/compiled/react${bundledReactChannel}/react.react-server`,
|
|
'react-dom$': `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`,
|
|
[`next/dist/compiled/react-dom${bundledReactChannel}$`]: `next/dist/compiled/react-dom${bundledReactChannel}/react-dom.react-server`,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
externals: [
|
|
...sharedExternals,
|
|
...(bundleType === 'pages' ? pagesExternals : appExternals),
|
|
externalsMap,
|
|
externalHandler,
|
|
],
|
|
...rest,
|
|
}
|
|
}
|
|
|
|
function calculateUniquePort(dev, turbo, experimental, bundleType) {
|
|
const devOffset = dev ? 1000 : 0
|
|
const turboOffset = turbo ? 200 : 0
|
|
const experimentalOffset = experimental ? 40 : 0
|
|
let bundleTypeOffset
|
|
|
|
switch (bundleType) {
|
|
case 'app':
|
|
bundleTypeOffset = 1
|
|
break
|
|
case 'pages':
|
|
bundleTypeOffset = 2
|
|
break
|
|
default:
|
|
bundleTypeOffset = 3
|
|
}
|
|
|
|
return 8888 + devOffset + turboOffset + experimentalOffset + bundleTypeOffset
|
|
}
|