cloudflare / cloudflare/vinext
Pages Router: production CSS is linked in chunk order, not module-graph order, so CSS-module overrides invert vs dev
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Problem
In a Pages Router production build, stylesheets are linked in **chunk-emission order**, not module-graph order. Dev links every CSS module separately in import order, so the two disagree and equal-specificity rules resolve the opposite way in production.
Concretely, given a base component and a second CSS module that overrides it:
```css
/* base.module.css — imported by a shared component */
.panel { position: absolute; visibility: hidden; }
```
```css
/* reset.module.css — imported from _app's graph */
.reset { display: none; }
```
Both selectors are one class (specificity 0,1,0), so **source order decides**. Vite emits each chunk's CSS with that chunk, so:
* `reset.module.css` is folded into the **entry** stylesheet (`_app..css`), linked first
* `base.module.css` lands in a **shared chunk** stylesheet, loaded later
The override therefore comes *before* what it overrides and loses, while in dev it comes after and wins. Measured on a real page (sheet index in `document.styleSheets`):
| | dev | production build |
| ------------------ | -------------- | ---------------- |
| base rule | sheet 23 | sheet 21 |
| overriding rule | sheet 24 ✅ wins | sheet 0 ❌ loses |
The visible result was a mobile flyout panel rendering open and in-flow at desktop width, shoving the page sideways — one inverted rule, whole-page damage.
Reproduction shape: the base CSS module must end up in a **shared chunk** and the overriding CSS module in the **entry** chunk. A trivial two-file app will inline everything into one chunk and not show it.
## Second symptom: most CSS is not in the SSR HTML
`collectAssetTags` only looks up the page module id and the `_app` module id in the SSR manifest, so the served HTML carried **2** `` tags while the hydrated document ended up with **~60** — the rest injected as JS chunks loaded. Final order is therefore chunk-load order, and the first paint is unstyled until hydration.
## Cause
`collectAssetTags` (`dist/server/pages-asset-tags.js`) resolves assets per module id from the SSR manifest, which records a chunk plus its *direct* imports. Nothing walks the chunk graph, so nothing establishes dependency-before-importer ordering.
## Fix
The client build already emits `.vite/manifest.json` with `imports`, `dynamicImports` and `css` per chunk — enough to reconstruct graph order. The patch below:
1. embeds a trimmed `{ file, imports, dynamicImports, css }` graph into the client-assets module at build time, and
2. walks it depth-first at request time, emitting imported chunks' CSS **before** the importer's, ahead of the existing manifest-ordered tags (the existing `seen` set then suppresses duplicates).
Following `dynamicImports` matters: a component behind `next/dynamic` still renders during SSR, so leaving its stylesheet out means it arrives after first paint and restyles the page.
Result on the same page: the overriding rule moves from sheet 0 to sheet 28 (now after the base rule at 17), the flyout renders correctly, SSR HTML goes from 2 to 61 stylesheet links, and no CSS finishes loading after first-contentful-paint.
## Caveats
This is a diff against built `dist` output — a description of the fix rather than a source patch. It has no tests, does not touch the App Router path (which uses its own emitter and React's `data-precedence` hoisting, so it likely needs a different treatment — cf. #2879), and does not address `build.cssCodeSplit: false`, which separately emits a single stylesheet that the SSR HTML then never links.
## Environment
* vinext `1.0.0-beta.6`
* Vite `8.1.0`
* Pages Router, `vinext build` + Cloudflare Workers
## Patch
```diff
diff --color -ruN --text a/dist/index.js b/dist/index.js
--- a/dist/index.js 2026-08-19 01:35:12
+++ b/dist/index.js 2026-08-19 01:35:12
@@ -3248,7 +3248,34 @@
if (fs.existsSync(ssrManifestPath)) try {
ssrManifest = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8"));
} catch {}
+ // Chunk import graph, used at request time to emit stylesheets in
+ // module-graph order. Vite emits a chunk's own CSS with the chunk,
+ // so an entry's CSS (e.g. `_app`) is linked before the CSS of the
+ // shared chunks it imports. Equal-specificity rules then resolve in
+ // the opposite order from dev, where every CSS module is its own
+ // link in graph order — a CSS-module "reset" meant to override a
+ // base component silently loses in production.
+ let cssGraph;
+ const clientManifestPath = path.join(clientDir, ".vite", "manifest.json");
+ if (fs.existsSync(clientManifestPath)) try {
+ const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, "utf-8"));
+ cssGraph = {};
+ for (const [key, entry] of Object.entries(clientManifest)) {
+ const imports = entry?.imports ?? [];
+ const css = entry?.css ?? [];
+ const dynamicImports = entry?.dynamicImports ?? [];
+ if (imports.length === 0 && dynamicImports.length === 0 && css.length === 0) continue;
+ cssGraph[key] = {
+ file: entry?.file,
+ imports,
+ dynamicImports: entry?.dynamicImports ?? [],
+ css
+ };
+ }
+ if (Object.keys(cssGraph).length === 0) cssGraph = void 0;
+ } catch {}
pagesClientAssetsModule = buildPagesClientAssetsModule({
+ cssGraph,
clientEntry: runtimeMetadata.clientEntryFile ?? void 0,
appBootstrapPreinitModules: runtimeMetadata.appBootstrapPreinitModules,
ssrManifest,
diff --color -ruN --text a/dist/server/pages-asset-tags.js b/dist/server/pages-asset-tags.js
--- a/dist/server/pages-asset-tags.js 2026-08-19 01:35:12
+++ b/dist/server/pages-asset-tags.js 2026-08-19 01:35:12
@@ -64,6 +64,53 @@
*
* Extracted from `entries/pages-server-entry.ts`.
*/
+/**
+* Stylesheets for a set of module ids, in module-graph order.
+*
+* Vite emits each chunk's CSS alongside that chunk, so an entry's own stylesheet
+* is linked before the stylesheets of the shared chunks it imports. Dev does the
+* opposite: every CSS module is its own link in import order. Where two CSS
+* modules style the same element with equal specificity — a "reset" class meant
+* to override a base component is the common case — the winner flips between dev
+* and production and the built page renders wrong.
+*
+* Walking the chunk graph depth-first and emitting imported chunks' CSS before
+* the importer's restores the dev cascade: dependencies first, importer last.
+*/
+function collectGraphOrderedCss(cssGraph, moduleIds) {
+ if (!cssGraph || !moduleIds || moduleIds.length === 0) return [];
+ const keyFor = (moduleId) => {
+ if (cssGraph[moduleId]) return moduleId;
+ for (const key in cssGraph) if (moduleId === key || moduleId.endsWith("/" + key)) return key;
+ return null;
+ };
+ const ordered = [];
+ const emitted = /* @__PURE__ */ new Set();
+ const visited = /* @__PURE__ */ new Set();
+ const walk = (key) => {
+ if (!key || visited.has(key)) return;
+ visited.add(key);
+ const entry = cssGraph[key];
+ if (!entry) return;
+ const imports = entry.imports ?? [];
+ for (let i = 0; i < imports.length; i++) walk(imports[i]);
+ // Dynamic entries are followed too: a component behind `next/dynamic` or
+ // `React.lazy` still renders during SSR, so its stylesheet has to be in
+ // the document. Left out, the chunk's CSS only arrives once the lazy
+ // chunk loads and restyles the page after first paint.
+ const dynamicImports = entry.dynamicImports ?? [];
+ for (let i = 0; i < dynamicImports.length; i++) walk(dynamicImports[i]);
+ const css = entry.css ?? [];
+ for (let i = 0; i < css.length; i++) {
+ const file = css[i];
+ if (!file || emitted.has(file)) continue;
+ emitted.add(file);
+ ordered.push(file);
+ }
+ };
+ for (let i = 0; i < moduleIds.length; i++) walk(keyFor(moduleIds[i]));
+ return ordered;
+}
function collectAssetTags(options) {
const m = resolveSsrManifest(options.manifest);
const tags = [];
@@ -86,6 +133,17 @@
seen.add(clientEntry);
tags.push("");
tags.push("<\/script>");
+ }
+ // Emit stylesheets in module-graph order first; `seen` then suppresses the
+ // manifest-ordered duplicates below, so the graph order is what the browser
+ // applies.
+ const graphCss = collectGraphOrderedCss(runtimeAssets.cssGraph, options.moduleIds);
+ for (let gi = 0; gi < graphCss.length; gi++) {
+ let gf = graphCss[gi];
+ if (gf.charAt(0) === "/") gf = gf.slice(1);
+ if (seen.has(gf)) continue;
+ seen.add(gf);
+ tags.push("<link rel=\"stylesheet\"" + nonceAttr + " href=\"" + href(gf) + "\" />");
}
if (m) {
const allFiles = [];
```
Contributor guide
Research direction
Start with dist/server/pages-asset-tags.js and the build-time code in dist/index.js, then inspect collectAssetTags and the client .vite/manifest.json fields it consumes. Trace moduleIds through collectGraphOrderedCss and verify that imported and dynamic chunk CSS is emitted before importer CSS, that SSR HTML includes the graph's stylesheets, and that existing manifest tags are not duplicated; the issue notes there are no tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, typescript, vite
- Domain
- build-system, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100