Skip to content

Pages Router: production CSS is linked in chunk order, not module-graph order, so CSS-module overrides invert vs dev #2991

Description

@james-elicx

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:

/* base.module.css — imported by a shared component */
.panel { position: absolute; visibility: hidden; }
/* 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.<hash>.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 <link rel="stylesheet"> 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 --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("<link rel=\"modulepreload\"" + nonceAttr + " href=\"" + href(clientEntry) + "\"" + preloadCrossOriginAttr + " />");
 		tags.push("<script type=\"module\"" + deferAttr + nonceAttr + " src=\"" + href(clientEntry) + "\"" + scriptCrossOriginAttr + "><\/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 = [];

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions