Replies: 1 comment
|
Worth separating two different things here, because they have different odds of getting fixed.
I'd file two separate things if you go the issue route: a docs request for |
|
Worth separating two different things here, because they have different odds of getting fixed.
I'd file two separate things if you go the issue route: a docs request for |
Uh oh!
There was an error while loading. Please reload this page.
Summary
TL;DR: In loose mode (the default), every shared component's CSS ends up as its own render-blocking stylesheet, and as far as I can tell from the source there is no merge pass for CSS chunks at all, even when chunks are requested by exactly the same set of routes in the same order. Is that intentional, or is a merge pass for CSS just missing?
Our situation
Next 16.3.0, App Router, Turbopack. Fairly large app, zero-runtime CSS-in-JS, so every component file emits a small CSS module, about 288 of them across the app. The production build turns that into 114 CSS chunks, and our heaviest routes load between 56 and 74 stylesheets depending on whether you count error/not-found segments. 64 of the 114 chunks contain a single component's CSS. The smallest one is 35 bytes.
I built a repro/measurement setup while chasing this: https://github.com/dlehmhus/nextjs-turbopack-css-merge-ceiling. The script reads
entryCSSFilesout of the client reference manifests and groups chunks by which routes actually request them.What the chunker does (from reading the source)
At first I suspected our CSS-in-JS emission (it imports CSS as
data:URIs), thennext/dynamic, but synthetic trees kept chunking fine no matter what shape I gave them. So I ended up reading the chunker source on thev16.3.0tag, and if I'm reading the Rust correctly, this is just how loose mode works:make_style_production_chunks(turbopack-core/src/chunk/chunking/style_production.rs), which turns module batches into chunks 1:1. The ecmascript path inproduction.rsgroups items by their chunk-group set and then merges under size heuristics. The style path doesn't seem to have any merge step at all.ChunkingConfigthat next-core sets up is{ max_merge_chunk_size: 100_000, ..Default::default() }(incrates/next-core/src/next_client/context.rs), somin_chunk_sizeandmax_chunk_count_per_groupare 0 and the size-driven merge triggers are off anyway.module_graph/module_batches.rs) break at chunk-group-set boundaries, at shared modules (anything reachable from two batches gets extracted into its own batch), and at dynamic imports.Why that explodes with a component library
The shared-module rule is the one that hurts. Import a component from two different places and its CSS becomes its own chunk, which means its own render-blocking request. For an app with a component library that's one request per shared component: our typography, spinner and button primitives all pay it. Components imported from exactly one spot stay in their importer's batch, so our header+footer CSS travels as a single 19-module chunk. The fragmentation tracks sharing, not size and not audience.
What I don't understand is why nothing merges afterwards. 27 of our chunks are requested by exactly the same 39 segment entries, in a consistent order, contiguous in every route's stylesheet list. As far as I can tell, merging those into one file changes nothing except the request count. No route gets bytes it doesn't need, and no cascade order changes. The JS side already does this kind of same-audience grouping. For us it's the difference between roughly 57 CSS requests and 8 to 10.
What I ruled out
.cssimports withdata:text/cssimports in the same build, and they chunk identically in every shape I tried.cssChunking: { type: 'graph' }doesn't get there either. On our app it plateaus around 119 chunks for anyrequestCostabove roughly 20k, andrequestCost: 0gives one chunk per module. Different algorithm, same outcome.next/dynamicmakes it maximally bad. Onedynamic()per component in the repro turns 250 modules into 250 chunks, which makes sense now, since dynamic imports are batch boundaries. But we barely usenext/dynamic, so that's not our problem.The question
Is the missing merge pass for CSS intentional (some ordering or correctness concern I'm not seeing), or just not implemented yet? If it's something you'd consider, chunks with identical chunk-group sets look like the safe first case, since they're contiguous and order-consistent everywhere they appear. Happy to share the measurement script and the full numbers from our app if useful.
Additional information
Example
https://github.com/dlehmhus/nextjs-turbopack-css-merge-ceiling
All reactions