Problem
Global (non-module) stylesheets imported by next/dynamic client components load after route CSS and CSS modules, so global rules incorrectly win the cascade. The Next.js compat fixture test/e2e/app-dir/next-dynamic-css/next-dynamic-css.test.ts (v16.2.6) fails on vinext: a global .global-class background overrides the CSS-module background on elements that use both.
Root cause: App Router route/global CSS is emitted by the RSC build and linked in the document head, while dynamic client imports are built in the client graph and their CSS is inserted at runtime by Vite's preload helper — appended at the end of <head>, after module CSS. Same stylesheet can also be emitted twice (RSC copy + client copy) with different hashed filenames, so nothing dedupes.
History
PR #1908 originally carried a fix and it was removed during review — twice:
- App-wide dedupe set (build-time): recorded every global CSS import seen in the RSC build and emptied matching client imports. Rejected — no route scoping; a stylesheet imported by route A's RSC graph would be dropped from a dynamic chunk that is route B's only source of that CSS.
- Replacement mechanism: deterministic
global-css-<hash of source path> chunks across RSC/SSR/client builds (so the same source file emits the same CSS filename everywhere), plus a Node.prototype.appendChild/insertBefore interceptor in the browser entry that deduped/reordered stylesheet links at insertion time, plus post-hoc rewriting of __vite_rsc_assets_manifest.js (sort global CSS first, drop CSS-only chunks' phantom JS entries that 404 on preload). Worked (the ported fixture passed, including a route-A/route-B over-dedupe regression), but was withdrawn as architecturally unacceptable: a platform monkey-patch intercepting every DOM insertion in user apps, string-surgery on another plugin's build output, setTimeout-faked load events, and a global-css- magic string hand-matched in four files.
The removed implementation is recoverable from the PR history (c7c51600 and earlier) for reference.
Proposed design
Own the stylesheet insertion seam instead of compensating downstream:
- Keep deterministic
global-css-* chunk naming across RSC/SSR/client builds (the sound part of the removed fix) so a stylesheet's identity is stable across environments.
- React stylesheet precedence: emit dynamic-chunk global CSS via React hoistable stylesheets (
<link rel="stylesheet" precedence="...">) from the existing DynamicPreloadChunks shim (which already has the module→CSS manifest), with global CSS in a lower precedence tier than CSS modules. React then owns ordering and dedupes by href, server- and client-side.
- Vite runtime insert becomes a no-op: Vite's preload helper skips inserting a stylesheet link whose href already exists in the document, so the SSR-emitted link suppresses the late duplicate without any interception.
- Fix the manifest at emission, not post-hoc: drop CSS-only chunks' placeholder JS entries in
generateBundle (they are never written to disk; preloading them 404s) instead of rewriting the serialized manifest after buildApp.
Risks to validate first
- Exact-href matching in Vite's preload dedupe (basePath / absolute URL normalization).
- Inline-CSS mode replaces links with
<style data-href> — link-based dedupe misses; needs the data-href equivalent.
- All vinext-emitted route CSS must participate in the precedence system, otherwise React's precedence tiers and raw
<link> tags interleave unpredictably. This is the biggest interop question.
Acceptance
- Port of
next-dynamic-css.test.ts passes (global vs module cascade order after prerender probing).
- Route-scoping regression: route A imports a stylesheet through RSC only; route B's sole source is a
next/dynamic client import; /b still receives the CSS.
- No 404s for phantom
global-css-* JS preloads.
- No
Node.prototype patching; no post-hoc manifest rewriting.
Problem
Global (non-module) stylesheets imported by
next/dynamicclient components load after route CSS and CSS modules, so global rules incorrectly win the cascade. The Next.js compat fixturetest/e2e/app-dir/next-dynamic-css/next-dynamic-css.test.ts(v16.2.6) fails on vinext: a global.global-classbackground overrides the CSS-module background on elements that use both.Root cause: App Router route/global CSS is emitted by the RSC build and linked in the document head, while dynamic client imports are built in the client graph and their CSS is inserted at runtime by Vite's preload helper — appended at the end of
<head>, after module CSS. Same stylesheet can also be emitted twice (RSC copy + client copy) with different hashed filenames, so nothing dedupes.History
PR #1908 originally carried a fix and it was removed during review — twice:
global-css-<hash of source path>chunks across RSC/SSR/client builds (so the same source file emits the same CSS filename everywhere), plus aNode.prototype.appendChild/insertBeforeinterceptor in the browser entry that deduped/reordered stylesheet links at insertion time, plus post-hoc rewriting of__vite_rsc_assets_manifest.js(sort global CSS first, drop CSS-only chunks' phantom JS entries that 404 on preload). Worked (the ported fixture passed, including a route-A/route-B over-dedupe regression), but was withdrawn as architecturally unacceptable: a platform monkey-patch intercepting every DOM insertion in user apps, string-surgery on another plugin's build output,setTimeout-fakedloadevents, and aglobal-css-magic string hand-matched in four files.The removed implementation is recoverable from the PR history (
c7c51600and earlier) for reference.Proposed design
Own the stylesheet insertion seam instead of compensating downstream:
global-css-*chunk naming across RSC/SSR/client builds (the sound part of the removed fix) so a stylesheet's identity is stable across environments.<link rel="stylesheet" precedence="...">) from the existingDynamicPreloadChunksshim (which already has the module→CSS manifest), with global CSS in a lower precedence tier than CSS modules. React then owns ordering and dedupes by href, server- and client-side.generateBundle(they are never written to disk; preloading them 404s) instead of rewriting the serialized manifest afterbuildApp.Risks to validate first
<style data-href>— link-based dedupe misses; needs the data-href equivalent.<link>tags interleave unpredictably. This is the biggest interop question.Acceptance
next-dynamic-css.test.tspasses (global vs module cascade order after prerender probing).next/dynamicclient import;/bstill receives the CSS.global-css-*JS preloads.Node.prototypepatching; no post-hoc manifest rewriting.