vercel / vercel/next.js

Turbopack: nested next/dynamic gets no SSR stylesheet link, causing FOUC and layout shift (App Router)

Open
#98,804 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

CSS Lazy Loading Module Resolution Turbopack
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/vlad-demchuk/next16-nested-dynamic-css-repro

To Reproduce
npm install
npm run build     # Turbopack, the Next 16 default
npm run check

The app is six files:

app/page.js                      server component
  └─ components/Resolver.js      "use client", imported statically
       └─ dynamic(WidgetX)            level 1
            └─ dynamic(VariantY)      level 2

WidgetX and VariantY each import their own CSS module and both keep the default ssr: true.
check.mjs reads the prerendered HTML, lists its <link rel="stylesheet"> tags, then locates the
CSS chunk holding each module's class name and reports whether that chunk is linked.

To confirm it by hand rather than through the script:

npx next start -p 3200

Open http://localhost:3200 and use view-source — not the Elements panel, since by then hydration
has already inserted the missing link. The server HTML contains exactly one <link rel="stylesheet">,
the level-1 one. Throttling the network (DevTools -> Network -> Slow 3G) and hard-reloading makes the
shift visible: the two columns stack full width first, then snap to 62% / 38% when the level-2
stylesheet arrives. On an unthrottled localhost the chunk is a few hundred bytes, so the flash is too
quick to catch.

Current vs. Expected behavior

Current — the level-2 stylesheet is not in the server HTML at all. It is emitted as a chunk, but
nothing links it, so the client chunk loader fetches it after hydration (plain <link>, no
data-precedence, renderBlockingStatus: "non-blocking"). VariantY therefore paints without
display: flex and without its 62% / 38% column widths, then reflows when the stylesheet lands.

Expected — the same <link rel="stylesheet" precedence="dynamic"> that the level-1 dynamic import
receives in the very same build.

Results from the repro:

build level 1 CSS linked in SSR HTML level 2 CSS linked in SSR HTML
16.3.4, Turbopack yes (data-precedence="dynamic") no
16.3.4, next build --webpack yes yes
16.4.0-canary.34, Turbopack yes no
16.0.11, Turbopack yes yes

On one and the same version the webpack build links both stylesheets while the Turbopack build
links only the first level, and the Turbopack build did link both on 16.0.x.

Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.5.0
  Available memory (MB): 32768
  Available CPU cores: 12
Binaries:
  Node: 24.19.0
  npm: 11.17.0
  pnpm: 10.20.0
Relevant Packages:
  next: 16.3.4 (also reproduced on 16.4.0-canary.34)
  react: 19.3.0
  react-dom: 19.3.0
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

Turbopack, CSS, Module Resolution, Lazy Loading (next/dynamic)

Which stage(s) are affected? (Select all that apply)

next build (local), next start (local), Vercel (Deployed)

Additional context

Regression range, measured on a real app (same application code, only the Next version changed).
Metric = number of CSS modules on one route delivered by the client loader instead of an SSR link:

next late CSS modules
16.0.0 1
16.0.11 1
16.1.0 24
16.1.7 24
16.2.12 29
16.3.4 29
16.3.5 29

16.2.x also raised total emitted CSS for that route from 978 KB to 1.16 MB, which may or may not be
the same change.

Impact in production on 16.3.4, on a landing page whose above-the-fold section is a level-2 dynamic:
CLS 0.4157, from a single shift of 0.3825 at 1543 ms — 83 ms after first contentful paint,
with the two columns and the widget wrapper as the shift sources. The statically imported
equivalent component on the same page measures CLS 0.

The behaviour looks at odds with the intent stated in Next's own source, though we may be misreading
how the pieces fit together:

  • packages/next/src/shared/lib/lazy-dynamic/loadable.tsx: /* During SSR, we need to preload the CSS from the dynamic component to avoid flash of unstyled content */
  • packages/next/src/shared/lib/lazy-dynamic/preload-chunks.tsx: // For stylesheets we actually need to render the CSS because nothing else is going to do it

Where we suspect this comes from — a pointer, not a diagnosis, as we don't know the Turbopack
internals: PreloadChunks renders its link from opts.modules looked up in
reactLoadableManifest, and the affected chunks are exactly the ones missing from that manifest.
Looking for why, we noticed the DFS in crates/next-api/src/module_graph.rs (tag v16.3.4, around
lines 204-210) records a dynamic entry and then returns GraphTraversalAction::Skip for it. If that
means the traversal does not descend into a dynamically imported module, a dynamic() declared
deeper would never be collected, which matches what we see. The skip itself looks like a sensible
optimisation — we mention it only because the symptom lines up.

experimental.cssChunking is not an option here: Turbopack rejects it with
"experimental.cssChunking is only supported with webpack". experimental.inlineCss: true does not
change the behaviour.

Workaround that keeps the lazy loading: move the level-2 dynamic() declarations into a module
reached by a plain import from a statically imported module. In our app this dropped late CSS
modules from 29 to 10 (the remaining 10 are ssr: false components, where late CSS is expected)
with byte-identical CSS output, the same chunk count, and code splitting intact — the variant still
lands in its own 1.8 KB lazy chunk, and the route keeps 73 lazy chunks versus 58 when the same
components are imported statically.

Related: #98287 (Turbopack omits __NEXT_DATA__.dynamicIds for next/dynamic in pages/_app;
same class of missing registration), #98292 (PR filling another hole in the Turbopack loadable
manifest), #83941 (Turbopack CSS prioritisation differs between dev and build).

Note: I first filed this as #98803 without a reproduction link and the bot closed it, as documented.
Re-filing with the repro, per its instructions.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the linked six-file reproduction with npm install, npm run build, and npm run check, then inspect view-source via next start -p 3200. Read packages/next/src/shared/lib/lazy-dynamic/loadable.tsx and preload-chunks.tsx alongside crates/next-api/src/module_graph.rs at v16.3.4 around lines 204-210, comparing Turbopack and webpack outputs. Done means the nested level-2 stylesheet appears in Turbopack SSR HTML without regressing lazy loading.

Written by the indexing model from the issue text.

Assessment

Tech stack
css, javascript, nextjs, react
Domain
build-system, frontend, performance, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.