Files
vercel__next.js/packages/next/next-runtime.webpack-config.js
Hendrik Liebau 3c4afc1a3a Run Cache Components dev validation on a worker thread (#96153)
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
2026-07-25 07:21:03 +02:00

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
}