Skip to content

App Router: next/dynamic global CSS loads after CSS modules, breaking cascade order #1931

Description

@NathanDrake2406

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions