Visitar URL original
feat: add the SSR manifest and collect server styles without a DOM (#… · webpack/webpack@f4d235e · GitHub
Skip to content

Commit f4d235e

Browse files
feat: add the SSR manifest and collect server styles without a DOM (#21913)
* feat: add the SSR manifest and collect server styles without a DOM A server render puts a lazy route's markup in the document long before the browser asks for the chunk carrying that route's stylesheet, so the page arrives unstyled. Two additive, opt-in primitives close that: - `SSRManifestPlugin` emits `ssr-manifest.json`, mapping each source module to the client assets it needs — its chunk, the splits loaded with it and the async ancestors it is reached through — so a server injects `modulepreload` and stylesheet tags for exactly what it rendered. Stylesheets are listed in the order they cascade, the rule the extracted HTML already writes them by. - The CSS runtimes collect styles on a document-less target (`target: "node"`), not only behind a runtime check on a universal bundle, so `__webpack_css_server_styles__` answers in a dedicated server build. A worker keeps the DOM path: it has no `fs` to read a stylesheet with. * fix: address review findings on the SSR manifest - annotate the plugin's schema options with the release that adds them - assert the shared chunk is listed before asserting nothing else is, so the filter cannot pass on an empty list - resolve the example's manifest from the server module rather than the working directory - shorten the changeset and one comment to the repository's limits * refactor: order the extracted page's stylesheets through the shared helper The cascade order the SSR manifest needs is the one the extracted HTML already computes, so the manifest's copy left two identical implementations in the tree. The html path now calls the shared helper and its own copy goes, which also covers the comparator from both sides. Adds a case for a route reached through two async ancestors: the manifest has to name their stylesheets too, and their order has to be stable when the cascade cannot place them. * fix: warn on an unresolvable publicPath and wait for a pending server stylesheet `SSRManifestPlugin` cannot resolve `output.publicPath: "auto"` — the browser computes it from the script url — so it falls back to `/`. Served from anywhere else, the urls the server prints are not the ones the runtime builds: nothing is adopted and every stylesheet is fetched again. Warn instead of failing silently. The css loading runtime treated any adopted `<link>` as already applied, which holds for one the parser saw in `<head>` but not for one a streamed render inserts later. Wait on a same-origin link that has no `sheet` yet; a cross-origin sheet never exposes one, so it keeps the render-blocking assumption. * docs: make the SSR example a real React app with a runnable server The example rendered a hardcoded string and never ran. It is now a React 19 app hydrated in the browser, with the route code-split so its stylesheet genuinely travels with a chunk the browser has not asked for — the case the SSR manifest exists to close. `node dist/server/main.mjs` serves it: `/` names the route's stylesheet from the manifest, `/inline` inlines the collected server styles, and the two are there to be compared. Locating the client build through `import.meta.url` did not work: in module code that is the source module's url, not the emitted bundle's, so the path resolved outside `dist` and the manifest read would have failed. Use the runtime public path instead. The manifest is re-read per render in development, so a client rebuild needs no restart, and the collected-styles registry now says that it is process-wide and carries no initial stylesheet. * docs: refresh the SSR example output after the rebase The css loading runtime grew by the `.catch` main added to the no-DOM read and the adopted-stylesheet check, so the example's stats output moves with it. * fix: reject directories before streaming the SSR example's client assets `/dist/client/` resolved to the client output directory itself, which `existsSync` accepted and `createReadStream` then failed on with an unhandled `EISDIR` — one unauthenticated request ended the process. Stat once, require a regular file, and destroy the response on a stream error instead. * test: filter the SSR example where ESM-only @babel/core cannot load The example gained a babel-loader rule for JSX, and `Examples.test.js` builds it inside Jest's vm, where @babel/core@8 cannot be required as CommonJS — the Deno job and every integration shard below Node 24.9 failed on it. Carry the same filter the other two babel-using examples already ship. * refactor: sort the css cascade through the shared comparators `compareCssChunkOrder` re-implemented what `lib/util/comparators.js` already exports, down to the tie-break `Array#sort` needs on the legacy node versions. Compose `compareNumbers` and `compareStrings` instead — `compareNumbers` also orders the `Infinity` a group's walk never reaches, which is why the hand-rolled version could not subtract. Also states in the SSR example that stylesheet order is per manifest entry, and that `precedence` is what orders stylesheets across entries. * build: follow output.module after experiments.outputModule was removed #22011 dropped `experiments.outputModule` from the schema, so the five configs setting it now fail validation. Read `output.module` instead — which is also what both harnesses read to decide whether to emit and run the bundle as `.mjs`. Also drops the `rsbuild` word from `cspell.json`, unused since the example was rewritten, and trims a comment in the example server back to two lines. * docs: refresh the SSR example output for the current runtime The css and chunk-loading runtime modules grew on main, so the example's stats output moves with them; module counts are unchanged. * fix: complete the SSR manifest for every entry shape Four gaps, each reproduced with a failing case first. `dependOn` and a per-entry `runtime` chunk were both dropped: the walk skipped every initial parent on the grounds that the document already carries it, which holds for independent entries but not for the one an entry is built against, and it never looked at the entrypoint's own runtime chunk. A server preloading a rendered module got a set the module cannot actually run from. A stylesheet was recognised by its `.css` name, so `output.cssFilename` with any other extension sent it to the sorted bucket and silently dropped the cascade order the manifest exists to state. Ask what webpack recorded when it emitted the asset instead; source maps come from `related.sourceMap` for the same reason. One file built in two layers is two modules emitting into different chunks, so the layer now qualifies the key rather than the two collapsing into one entry listing both chunks' files. * docs: refresh the SSR example output for the current runtime * refactor: follow the lib reorganization in the SSR manifest plugin `Chunk`, `ChunkGraph`, `ChunkGroup`, `ModuleGraph`, `Entrypoint` and `HotUpdateChunk` moved under `graph/` and `Module`/`NormalModule` under `module/`. `HotUpdateChunk` has no compatibility shim at its old path, so the require broke outright; the rest resolved through shims but typed as the module rather than the instance. * refactor: move SSRManifestPlugin out of the lib root The root of `lib/` is the core a build is made of plus the shims holding old `webpack/lib/<Name>` paths open; every plugin lives with what it acts on. `SSRManifestPlugin` describes the emitted asset set, so it belongs beside `ManifestPlugin` in `lib/output/`. It has not shipped under the old path, so it owes no shim — `yarn find-deep-imports --check` agrees. States the rule in AGENTS.md, since the map is what a reader consults before adding a file. Also repoints the `Validation` case at the package root: that file moved into `test/unitCases/`, which left its `require("..")` resolving to `test/`. * fix: brace both branches of the source-map lookup `curly` wants the `if` braced once its `else if` is, which is what reddened `lint`. * build: regenerate the SSRManifestPlugin validator The shared `absolutePath` schema learned to accept a `file:` URL, so the precompiled validator this plugin ships had to be regenerated against it. * test: cover the source-map filtering in the SSR manifest The manifest drops a chunk's source maps from its auxiliary files, and nothing exercised it: no ssr case set a devtool. Two SourceMapDevToolPlugin instances give one chunk a single map and another a pair, which is how webpack records `related.sourceMap` as a string and as an array. * fix(css): do not fail a chunk on an adopted stylesheet that never reports A server-rendered <link> whose request already failed replays no event, so the wait for its `sheet` ran to `chunkLoadTimeout`, removed the link from the document and rejected with a ChunkLoadError. Only a link webpack itself created now times out as an error; an adopted one resolves and stays put. Also hold the SSR example to Babel 8's real floor, Node 24.11. * perf(css): emit the server-rendered stylesheet wait only where SSR needs it Adopting a <link> a server printed costs 125 bytes in the css loading runtime, which every css build carried. SSRManifestPlugin now declares `hasServerRenderedStylesheets` as a tree runtime requirement and the runtime reads it, so a build without the plugin emits the bytes it emitted before.
1 parent 09235a9 commit f4d235e

114 files changed

Lines changed: 2740 additions & 162 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": minor
3+
---
4+
5+
Add `SSRManifestPlugin` and fix server-rendered CSS collection and adoption.

‎AGENTS.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ The directory listings below are the canonical map of the repository. **Whenever
5656
**Source**
5757

5858
- `lib/` — Main source code (CommonJS only; types declared via JSDoc `@typedef`).
59+
Its **root is the core** — what a build is made of (`Compilation`, `Compiler`,
60+
`Dependency`, `MultiCompiler`) and what publishes it (`webpack.js`, `index.js`)
61+
— alongside the re-export shims that hold the old `webpack/lib/<Name>` paths
62+
open for the ecosystem. **A plugin never belongs there.** It goes in the
63+
directory for what it acts on — the asset set in `lib/output/`, the module
64+
graph in `lib/optimize/`, the entry in `lib/entry/` — and when nothing fits, a
65+
new directory is added, with its bullet in this map, in the same commit. A file
66+
that leaves the root owes a shim at its old path only once it has shipped under
67+
it; `yarn find-deep-imports --check` and `deepPathShims.unittest.js` are what
68+
say so.
5969
- `lib/asset/` — Asset modules (images, fonts, raw files); includes the `asset/webmanifest` type that parses `<link rel="manifest">` icon URLs.
6070
- `lib/async-modules/` — Top-level await.
6171
- `lib/bun/` — Bun target externals preset (`bun:*` and node.js built-in modules).
@@ -155,7 +165,10 @@ The directory listings below are the canonical map of the repository. **Whenever
155165
`output.path`, rather than the modules it writes them from: `CopyPlugin` adds
156166
to it and `CleanPlugin` prunes it — the two `output.copy` and `output.clean`
157167
turn on — while `BannerPlugin` rewrites what an asset holds and
158-
`ManifestPlugin` emits a description of the rest. How a name or a format is
168+
`ManifestPlugin` emits a description of the rest. `SSRManifestPlugin` emits
169+
the description a server renders from: which client files each source module
170+
needs, so an SSR response can name the stylesheet of a route the browser has
171+
not asked for yet. How a name or a format is
159172
decided is not this: that is `lib/template/`, `lib/library/` and `lib/devtool/`.
160173
- `lib/performance/` — Asset/entrypoint size hints.
161174
- `lib/prefetch/` — Prefetch and preload, which are two mechanisms sharing a word:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* This file was automatically generated.
3+
* DO NOT MODIFY BY HAND.
4+
* Run `yarn fix:special` to update
5+
*/
6+
7+
export interface SSRManifestPluginOptions {
8+
/**
9+
* The base directory used to compute the source-module keys (defaults to the compiler context).
10+
* @since 5.111.0
11+
*/
12+
context?: string;
13+
/**
14+
* Specifies the filename of the emitted manifest on disk. By default the plugin will emit `ssr-manifest.json` inside the 'output.path' directory.
15+
* @since 5.111.0
16+
*/
17+
filename?: string;
18+
}

‎examples/ssr/App.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React, { Suspense } from "react";
2+
3+
// The shell both builds render: the server with the route module already
4+
// imported, the browser with a lazy one it still has to fetch.
5+
export function App({ Page }) {
6+
return (
7+
<div className="app">
8+
<h1 className="title">webpack server-side rendering</h1>
9+
<Suspense fallback={<p className="pending">Loading the route…</p>}>
10+
<Page />
11+
</Suspense>
12+
</div>
13+
);
14+
}

0 commit comments

Comments
 (0)