feat: add server-side rendering building blocks (externals preset, SSR manifest, collected CSS) - #21444
feat: add server-side rendering building blocks (externals preset, SSR manifest, collected CSS)#21444alexander-akait wants to merge 16 commits into
Conversation
🦋 Changeset detectedLatest commit: 6057986 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
This PR is packaged and the instant preview is available (6057986). Install it locally:
npm i -D webpack@https://pkg.pr.new/webpack@6057986
yarn add -D webpack@https://pkg.pr.new/webpack@6057986
pnpm add -D webpack@https://pkg.pr.new/webpack@6057986 |
| * DO NOT MODIFY BY HAND. | ||
| * Run `yarn fix:special` to update | ||
| */ | ||
| const r=/^(?:[A-Za-z]:[\\/]|\\\\|\/)/;function e(t,{instancePath:n="",parentData:a,parentDataProperty:o,rootData:s=t}={}){if(!t||"object"!=typeof t||Array.isArray(t))return e.errors=[{params:{type:"object"}}],!1;{const n=0;for(const r in t)if("context"!==r&&"filename"!==r)return e.errors=[{params:{additionalProperty:r}}],!1;if(0===n){if(void 0!==t.context){let n=t.context;const a=0;if(0===a){if("string"!=typeof n)return e.errors=[{params:{type:"string"}}],!1;if(n.includes("!")||!0!==r.test(n))return e.errors=[{params:{}}],!1}var i=0===a}else i=!0;if(i)if(void 0!==t.filename){let n=t.filename;const a=0;if(0===a){if("string"!=typeof n)return e.errors=[{params:{type:"string"}}],!1;if(n.includes("!")||!1!==r.test(n))return e.errors=[{params:{}}],!1;if(n.length<1)return e.errors=[{params:{}}],!1}i=0===a}else i=!0}}return e.errors=null,!0}module.exports=e,module.exports.default=e; No newline at end of file |
There was a problem hiding this comment.
This file is auto-generated (schemas/**/*.check.js) — its header reads "This file was automatically generated. DO NOT MODIFY BY HAND. Run yarn fix:special". The flagged dead store is emitted by the ajv schema compiler, so it can't be hand-edited (any change would be reverted on the next yarn fix:special), and it's benign codegen shared by all the precompiled validators. No action here.
Generated by Claude Code
| * DO NOT MODIFY BY HAND. | ||
| * Run `yarn fix:special` to update | ||
| */ | ||
| const r=/^(?:[A-Za-z]:[\\/]|\\\\|\/)/;function e(t,{instancePath:n="",parentData:a,parentDataProperty:o,rootData:s=t}={}){if(!t||"object"!=typeof t||Array.isArray(t))return e.errors=[{params:{type:"object"}}],!1;{const n=0;for(const r in t)if("context"!==r&&"filename"!==r)return e.errors=[{params:{additionalProperty:r}}],!1;if(0===n){if(void 0!==t.context){let n=t.context;const a=0;if(0===a){if("string"!=typeof n)return e.errors=[{params:{type:"string"}}],!1;if(n.includes("!")||!0!==r.test(n))return e.errors=[{params:{}}],!1}var i=0===a}else i=!0;if(i)if(void 0!==t.filename){let n=t.filename;const a=0;if(0===a){if("string"!=typeof n)return e.errors=[{params:{type:"string"}}],!1;if(n.includes("!")||!1!==r.test(n))return e.errors=[{params:{}}],!1;if(n.length<1)return e.errors=[{params:{}}],!1}i=0===a}else i=!0}}return e.errors=null,!0}module.exports=e,module.exports.default=e; No newline at end of file |
There was a problem hiding this comment.
Same as the sibling thread: this is the auto-generated ajv validator (yarn fix:special output, "DO NOT MODIFY BY HAND"), so the dead store can't be hand-edited and is benign compiler codegen. No action.
Generated by Claude Code
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #21444 +/- ##
========================================
Coverage 95.10% 95.10%
========================================
Files 703 704 +1
Lines 90753 90896 +143
Branches 27396 27438 +42
========================================
+ Hits 86309 86450 +141
- Misses 4444 4446 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will improve performance by 31.4%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Memory | benchmark "asset-modules-bytes", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' |
1,208.2 KB | 785.7 KB | +53.78% |
| ⚡ | Memory | benchmark "many-modules-interop-runtime", scenario '{"name":"mode-production","mode":"production"}', measure 'exec' |
24.4 KB | 19.9 KB | +22.82% |
| ⚡ | Memory | benchmark "asset-modules-bytes", scenario '{"name":"mode-production","mode":"production"}' |
7.4 MB | 6.2 MB | +20.13% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/ssr-vite-rspack-webpack-hwxuzr (6057986) with main (b6ccff1)
b709cbc to
cbe8085
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0c0966b. Configure here.
…manifest Maps each source module to the client chunk JS, CSS and asset files it needs, so a server-side renderer can inject modulepreload/stylesheet links and avoid FOUC during hydration (webpack's analog to Vite's build.ssrManifest).
Emitting at PROCESS_ASSETS_STAGE_SUMMARIZE let RealContentHashPlugin rewrite the hashed filenames inside the already-emitted manifest, which invalidated the persistent cache pack. Emit at PROCESS_ASSETS_STAGE_REPORT instead so filenames are final and the manifest is not rewritten.
Drive a real universal SSR render that reads the SSR manifest for modulepreload links and __webpack_css_server_styles__ for critical css, then assert the composed HTML document ties both primitives together.
require("fs") in the universal module bundle resolved through the createRequire
external, which is not reliably callable in the ESM test runner on Node 10-18.
Use dynamic import(), matching the CSS SSR runtime's fs access.
Demonstrate SSRManifestPlugin, __webpack_css_server_styles__ and externalsPresets.nodeModules composing into a server-side rendering flow.
Native CSS minification now applies in production, so the example's recorded asset sizes changed.
`output.publicPath: "auto"` resolves in the browser from the script URL, which a build-time manifest cannot do. Emitting the bare filename made injected preloads resolve against the rendered page's URL, so a nested route requested the wrong asset. Map it to "/" as `ManifestPlugin` does. Also drop the redundant `experiments` from the example: `css` defaults to "auto" and the universal target already implies `outputModule`.
Each module was mapped only to the chunk it lives in, so a route sharing a split-out chunk left the client to discover that chunk after fetching the route. Follow dependencies (never importers, which would drag in unrelated routes) and async ancestors, matching what Vite's SSR manifest describes.
Split the example into a client build and a Node render build so every SSR building block is exercised end to end: both manifests side by side, the CSS collected while rendering without a DOM, the node/nodeModules externals presets, non-emitting asset modules, and import.meta.env.
A `target: "node"` build that opts into emitting stylesheets (`generator.css.exportsOnly: false`) crashed with "document is not defined": the no-DOM branches of the CSS loading and inject runtimes were gated on the neutral platform alone, so a target known to have no DOM fell through to the link/style-element code. Select the no-DOM branch statically there instead, and require the global that backs the style registry whenever that branch is emitted. Lets the SSR example build for node, where `import.meta.env.SSR` is true.
The nested-async case exercises a route reached through another async chunk, where the manifest has to list the ancestor the browser loads it through; the validation case covers the plugin's schema-rejection path.
They are deliberately the wrong type, so the types-test project rejected the literal.
getRequiredChunks walked every chunk module's outgoing connections per chunk, so a module shared by N chunks had its connections iterated N times. Collapse the module graph to chunk-to-chunk edges in a single pass and walk those instead: 29.1ms -> 23.4ms on a 201-chunk build whose 800 shared modules each land in 200 chunks.
The no-DOM branch was selected from `environment.document` alone, which is
also false for `target: "webworker"` — routing a worker's CSS chunk loading
through `import('fs')`, which a worker cannot resolve. Require the node
platform as well, so every other document-less target keeps the link code it
had before.
The manifest sorted every file alphabetically, so a route split across two stylesheets handed the server an arbitrary cascade — `alpha.css` before the `page.css` its source imports first. Stylesheets now keep the order the extracted HTML writes them in, and a config case pins that a `<link>` a server printed is adopted by the browser runtime rather than requested again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8n6KpLAPpLZdLrovcDnAu
c5c7f7d to
6057986
Compare
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: ⛔ Files ignored due to path filters (5)
📒 Files selected for processing (59)
📝 WalkthroughWalkthroughChangesThe pull request adds SSR runtime and manifest support
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Superseded by #21913, which carries the SSR manifest and the document-less CSS collection rebased onto current Generated by Claude Code |
Generated code sizeComparing
No asset that both runs emit changed size — everything below is new or deleted. 34 asset(s) this pull request adds, biggest 20 by raw size
No runtime that both runs build changed which runtime modules it carries. 9 runtime(s) this pull request adds or no longer builds
Built |

Summary
Adds the core building blocks for server-side rendering, aligning webpack with the SSR features of Vite and Rspack/Rsbuild. Three additive, opt-in primitives:
externalsPresets.nodeModules— externalize installed packages (requests resolving intonode_modules) so a Node server buildrequires them at runtime instead of inlining them.SSRManifestPlugin— emitssr-manifest.jsonmapping each source module to the client assets (JS chunks + CSS) it needs, so a server can injectmodulepreload/stylesheet tags for exactly what it rendered.__webpack_css_server_styles__— a module variable returning the CSS collected during a server render (from the existing server style registry), for critical-CSS inlining.An
examples/ssrexample and a composition test (test/configCases/ssr/render) show the manifest and collected CSS producing a rendered document together. The relatedimport.meta.env.SSRflag is handled separately in #21440.What kind of change does this PR introduce?
feat — a new externals preset, a new plugin, and a new module variable.
Did you add tests for your changes?
Yes —
test/configCases/externals/node-modules-preset,test/configCases/ssr/manifest,test/configCases/css/server-styles, andtest/configCases/ssr/render(composition), plus an updatedDefaultssnapshot.Does this PR introduce a breaking change?
No. Everything is additive and opt-in:
externalsPresets.nodeModulesdefaults tofalse, and the plugin and module variable only emit runtime code when used.If relevant, what needs to be documented once your changes are merged or what have you already documented?
externalsPresets.nodeModules,SSRManifestPlugin, and the__webpack_css_server_styles__module variable should be documented on webpack.js.org (Configuration / Plugins / Module Variables). Theexamples/ssrREADME already documents end-to-end usage.Use of AI
AI was used: implemented with Claude (Claude Code). I directed the design decisions (the resolve-based externals check, the manifest shape and concatenated-module handling, exposing the collected CSS as a module variable backed by a small runtime accessor), reviewed the diff and tests, and verified the targeted ConfigTestCases suites,
tsc, and lint locally. I also verified via composition that the primitives do not all fit a single pure-nodebuild today (the CSS server registry currently requires a universal target), which is noted for follow-up rather than forced here.🤖 Generated with Claude Code
Generated by Claude Code
Note
Medium Risk
Changes generated CSS runtime code across node, universal, and webworker targets; incorrect branching could break CSS loading or bloat worker bundles, though coverage is broad.
Overview
Adds
SSRManifestPlugin, which emitsssr-manifest.jsonmapping each source module to the client JS/CSS (and related chunks) needed formodulepreload/ stylesheet tags after a server render. Chunk edges follow co-loaded splits and async ancestors, without pulling in unrelated routes that share a chunk.Extends
__webpack_css_server_styles__so styles are collected on document-less Node builds (target: "node"), not only behind a runtimedocumentcheck on universal bundles. CSS inject/load runtimes now branch on Node vs neutral vs webworker: Node may read emitted CSS from disk via dynamicimport('fs'); webworker builds keep DOM link loading and omit the disk path.Ships an
examples/ssrdual client/server setup plus config tests for manifest shape, nested async preloads, shared chunks, Node CSS collection, and end-to-end render composition.Reviewed by Cursor Bugbot for commit c5c7f7d. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit