Before filing
Area
Performance
Problem or need
CSS delivery emits one stylesheet per component, with no grouping step. A single route ships one render-blocking <link> per component under Link (contact-book-manager: 7 on one page), or one <style> block per component under Module (commerce: 15).
This costs on two independent axes, both measured.
1. Compression. Per-component files each carry their own compression window, so the shared token vocabulary (property names, marker attributes, color literals) can't be exploited across them:
contact-book-manager, 7 component CSS files
7 separate files : raw 19,240 B gzip 5,049 B
1 bundled file : raw 19,241 B gzip 3,122 B
saving : gzip 1,927 B (38.2%)
2. Style recalculation. The engine walks each stylesheet's rule buckets per element, so sheet count carries a real per-element cost independent of rule count. Measured by holding the rules byte-identical and varying only how many CSSStyleSheet objects they're spread across. All arms verified to produce identical computed styles across ~16,500 values before timings were trusted:
| page |
split (as shipped) |
bundled |
atomized (1 rule/sheet) |
| product |
15 sheets · 1.271 ms |
2 · 1.127 ms (−11.3%) |
183 · 1.760 ms (+38.4%) |
| home |
14 sheets · 1.079 ms |
2 · 1.038 ms (−3.9%) |
175 · 1.564 ms (+44.9%) |
The penalty persists when the sheets are already constructed and only matching is timed (+24.9% / +33.2% for atomized), so it is matching cost, not parse cost. Slope ≈ 1.5–2 µs per stylesheet per full-document restyle.
Scale honestly: at today's 7–15 sheets the recalc component is 0.04–0.18 ms — real, reproducible, correctly signed, and small. The dominant win is network (6 fewer render-blocking requests + 1,927 B gzip, both on the FCP/LCP critical path). The recalc measurement matters mainly because it establishes there is no tradeoff to weigh: grouping does not cost render time.
For scale: grouping saves more bytes (1,927 B) than removing all Light DOM scoping markers would (1,596 B), and unlike that option it costs nothing in correctness.
Who would benefit?
Every app rendering more than a couple of components per route — i.e. essentially all non-trivial WebUI apps. The benefit grows with component count per route, so it helps most exactly where apps are largest.
Desired outcome
A --css-bundle flag that composes with --css, not a fourth CssStrategy variant.
CssStrategy (crates/webui-parser/src/lib.rs:165) is a delivery mechanism: Link (external files), Style (inline <style>), Module (importmap data-URI + adopted stylesheets). Bundling is a grouping concern and is orthogonal to all three:
|
today |
with --css-bundle |
--css link |
N <link> tags |
1 route <link> + shared chunk <link>s |
--css style |
N <style> blocks |
1 <style> block |
--css module |
N importmap entries |
1 entry per route + shared chunks |
This is not theoretical: the measurement above was taken on commerce, which builds with --css module. Grouping 15 inline sheets to 2 produced the −11.3%. Folding bundle into the delivery enum would make it impossible to express "module delivery, grouped" — which is exactly the configuration that was measured.
Plus shared-chunk splitting. Grouping strictly per-route duplicates any component used by more than one route. Routes sharing a component set should get a shared chunk, so <mp-navbar> is downloaded and parsed once across the whole app rather than once per route bundle.
Concrete example
webui build ./src --css link --css-bundle --asset-file-name-template "[name]-[hash].[ext]"
<!-- today -->
<link rel="stylesheet" href="/css/mp-navbar.css">
<link rel="stylesheet" href="/css/mp-product-card.css">
<link rel="stylesheet" href="/css/mp-price.css">
<!-- ...4 more... -->
<!-- with --css-bundle: shared chunk + route-local remainder -->
<link rel="stylesheet" href="/css/chunk-mp-navbar-a3f9c1.css">
<link rel="stylesheet" href="/css/route-product-7b2e04.css">
Constraints
Reuse the existing component-asset chunker — do not write a second one. crates/webui/src/component_assets/ already implements exactly this algorithm for ESM assets, and the doc comment on render_component_assets already states the rule this issue needs: "Components used by one requested root stay inline, while components with an identical multi-root consumer set are emitted once in a shared chunk."
Directly reusable:
graph.rs::plan_component_assets — builds a component × root consumer bitset (Vec<u64>), classifies each component as unused / root-local / shared, sorts shared components by consumer row so identical sets are adjacent, then group_shared_components collapses each run into one ChunkPlan. This is the chunking algorithm; it needs no CSS-specific logic.
traversal.rs — GraphIndex plus TraversalScratch::collect, an iterative (explicit-stack, no recursion) reachability walk with generation-marked scratch reused across roots, so N roots cost no extra allocation.
entry_mask — components reachable from the entry are excluded from chunking as already-loaded prerequisites. CSS needs the identical concept for the app-shell sheet.
AssetFileNameTemplate (crates/webui-parser/src/asset_filename.rs) — already supports [name], [hash] (SHA-256 truncated to 8 hex), [ext]. Content hashing does not need to be built.
validate_unique_asset_file_names and metafile::render_metafile (esbuild-compatible) — reusable as-is.
ComponentStyleClosure in the protocol already models per-component style dependencies, and retain_entry_protocol already prunes it.
The one genuine blocker. traversal.rs::enqueue_dependency hard-errors on routes:
Some(Fragment::Route(_)) => return Err(routes_unsupported(owner)),
(diagnostic COMPONENT_ASSETS_WITH_ROUTES, help: "routes cannot be combined with static component assets"). Route-level CSS bundling is entirely about routes, so plan_component_assets cannot be called with routes as roots today.
This makes the shape of the refactor concrete — a three-way split rather than a straight reuse:
- Chunking core — consumer bitset +
group_shared_components, generic over opaque "unit" and "consumer" ids. Knows nothing about ESM, CSS, or routes. Pure and independently testable.
- Traversal policy — two callers over shared
GraphIndex/TraversalScratch: component-asset roots (rejects routes, preserving today's diagnostic) and CSS routes (walks them).
- Renderer — ESM module emission vs CSS concatenation.
Only (1) is shared verbatim today; (2) is where the route restriction lives and must be parameterized rather than deleted, so the existing COMPONENT_ASSETS_WITH_ROUTES diagnostic keeps firing for ESM assets.
Other constraints:
- Cache granularity is the real tradeoff and belongs in the docs. A bundle invalidates on any component edit; 7 files invalidate one. Content hashing plus cold-cache first visits (what FCP/LCP grade) should outweigh it for most apps, but it's a genuine regression for apps shipping small frequent CSS edits to returning users. This is why
--css-bundle should be opt-in rather than the default. Shared-chunk splitting also mitigates it: stable shared components stay in a stable chunk that survives route-local edits.
- Cannot merge across a shadow boundary. A component opting into Shadow via
<template shadowrootmode="open"> owns its own tree and needs its own sheet. The measured bundled arm above is 2 sheets, not 1, for exactly this reason.
- Cascade order must be preserved exactly. Today's order is deterministic via
document_style_resources insertion order (crates/webui-handler/src/lib.rs:1370); reordering would be a silent visual regression. Worth an equivalence test asserting computed styles are unchanged before/after bundling — the same technique used to validate the measurement above.
- Orthogonal to Light DOM marker stamping; no interaction with
:where([data-wl-*]) qualifiers.
emit_route_style_preloads must be generalized to chunks, not removed. CSS <link> tags are written into the streaming writer at the point each component's style closure is emitted (crates/webui-handler/src/lib.rs:1398) — they are not hoisted into <head>. A component rendering deep in the body therefore has its stylesheet discovered late, and emit_route_style_preloads exists to hoist that discovery into the head for every tag in the entry + route-chain closure not yet written. Bundling changes how many files exist, not where in the document they are referenced, so the late-discovery problem survives it completely. Two changes are needed:
- Preload per chunk href rather than per component tag, deduping against emitted chunks instead of
document_style_resources component tags.
- A route under bundling still fetches its route-local bundle plus every shared chunk it consumes, so there remain multiple requests to overlap — and each hint now covers a whole chunk of CSS instead of one component's, so the payoff per hint goes up. Shared chunks are the highest-value preload targets in the app: consumed by several routes, most likely to be cache-warm, and referenced from whichever consuming component happens to render first.
Alternatives or workarounds
- A fourth
CssStrategy::Bundle — the original framing of this issue, and wrong: it conflates grouping with delivery and cannot express module + grouped, which is the configuration actually measured.
- Whole-app bundle — simpler, but ships unused CSS and regresses small routes. Shared-chunk splitting is the better form of the same idea.
- Strict per-route bundles, no shared chunks — duplicates common components across every route bundle, trading request count for payload and cache churn.
- HTTP/2 multiplexing makes request count free — reduces connection overhead, but the sheets stay render-blocking and it does nothing for the 38% compression loss.
- Rely on an external bundler/CDN — pushes a framework-level concern onto every consumer, and it can't see route→component mapping without duplicating the projection manifest.
Before filing
Area
Performance
Problem or need
CSS delivery emits one stylesheet per component, with no grouping step. A single route ships one render-blocking
<link>per component underLink(contact-book-manager: 7 on one page), or one<style>block per component underModule(commerce: 15).This costs on two independent axes, both measured.
1. Compression. Per-component files each carry their own compression window, so the shared token vocabulary (property names, marker attributes, color literals) can't be exploited across them:
2. Style recalculation. The engine walks each stylesheet's rule buckets per element, so sheet count carries a real per-element cost independent of rule count. Measured by holding the rules byte-identical and varying only how many
CSSStyleSheetobjects they're spread across. All arms verified to produce identical computed styles across ~16,500 values before timings were trusted:The penalty persists when the sheets are already constructed and only matching is timed (+24.9% / +33.2% for
atomized), so it is matching cost, not parse cost. Slope ≈ 1.5–2 µs per stylesheet per full-document restyle.Scale honestly: at today's 7–15 sheets the recalc component is 0.04–0.18 ms — real, reproducible, correctly signed, and small. The dominant win is network (6 fewer render-blocking requests + 1,927 B gzip, both on the FCP/LCP critical path). The recalc measurement matters mainly because it establishes there is no tradeoff to weigh: grouping does not cost render time.
For scale: grouping saves more bytes (1,927 B) than removing all Light DOM scoping markers would (1,596 B), and unlike that option it costs nothing in correctness.
Who would benefit?
Every app rendering more than a couple of components per route — i.e. essentially all non-trivial WebUI apps. The benefit grows with component count per route, so it helps most exactly where apps are largest.
Desired outcome
A
--css-bundleflag that composes with--css, not a fourthCssStrategyvariant.CssStrategy(crates/webui-parser/src/lib.rs:165) is a delivery mechanism:Link(external files),Style(inline<style>),Module(importmap data-URI + adopted stylesheets). Bundling is a grouping concern and is orthogonal to all three:--css-bundle--css link<link>tags<link>+ shared chunk<link>s--css style<style>blocks<style>block--css moduleThis is not theoretical: the measurement above was taken on
commerce, which builds with--css module. Grouping 15 inline sheets to 2 produced the −11.3%. Foldingbundleinto the delivery enum would make it impossible to express "module delivery, grouped" — which is exactly the configuration that was measured.Plus shared-chunk splitting. Grouping strictly per-route duplicates any component used by more than one route. Routes sharing a component set should get a shared chunk, so
<mp-navbar>is downloaded and parsed once across the whole app rather than once per route bundle.Concrete example
webui build ./src --css link --css-bundle --asset-file-name-template "[name]-[hash].[ext]"Constraints
Reuse the existing component-asset chunker — do not write a second one.
crates/webui/src/component_assets/already implements exactly this algorithm for ESM assets, and the doc comment onrender_component_assetsalready states the rule this issue needs: "Components used by one requested root stay inline, while components with an identical multi-root consumer set are emitted once in a shared chunk."Directly reusable:
graph.rs::plan_component_assets— builds acomponent × rootconsumer bitset (Vec<u64>), classifies each component as unused / root-local / shared, sorts shared components by consumer row so identical sets are adjacent, thengroup_shared_componentscollapses each run into oneChunkPlan. This is the chunking algorithm; it needs no CSS-specific logic.traversal.rs—GraphIndexplusTraversalScratch::collect, an iterative (explicit-stack, no recursion) reachability walk with generation-marked scratch reused across roots, so N roots cost no extra allocation.entry_mask— components reachable from the entry are excluded from chunking as already-loaded prerequisites. CSS needs the identical concept for the app-shell sheet.AssetFileNameTemplate(crates/webui-parser/src/asset_filename.rs) — already supports[name],[hash](SHA-256 truncated to 8 hex),[ext]. Content hashing does not need to be built.validate_unique_asset_file_namesandmetafile::render_metafile(esbuild-compatible) — reusable as-is.ComponentStyleClosurein the protocol already models per-component style dependencies, andretain_entry_protocolalready prunes it.The one genuine blocker.
traversal.rs::enqueue_dependencyhard-errors on routes:(diagnostic
COMPONENT_ASSETS_WITH_ROUTES, help: "routes cannot be combined with static component assets"). Route-level CSS bundling is entirely about routes, soplan_component_assetscannot be called with routes as roots today.This makes the shape of the refactor concrete — a three-way split rather than a straight reuse:
group_shared_components, generic over opaque "unit" and "consumer" ids. Knows nothing about ESM, CSS, or routes. Pure and independently testable.GraphIndex/TraversalScratch: component-asset roots (rejects routes, preserving today's diagnostic) and CSS routes (walks them).Only (1) is shared verbatim today; (2) is where the route restriction lives and must be parameterized rather than deleted, so the existing
COMPONENT_ASSETS_WITH_ROUTESdiagnostic keeps firing for ESM assets.Other constraints:
--css-bundleshould be opt-in rather than the default. Shared-chunk splitting also mitigates it: stable shared components stay in a stable chunk that survives route-local edits.<template shadowrootmode="open">owns its own tree and needs its own sheet. The measuredbundledarm above is 2 sheets, not 1, for exactly this reason.document_style_resourcesinsertion order (crates/webui-handler/src/lib.rs:1370); reordering would be a silent visual regression. Worth an equivalence test asserting computed styles are unchanged before/after bundling — the same technique used to validate the measurement above.:where([data-wl-*])qualifiers.emit_route_style_preloadsmust be generalized to chunks, not removed. CSS<link>tags are written into the streaming writer at the point each component's style closure is emitted (crates/webui-handler/src/lib.rs:1398) — they are not hoisted into<head>. A component rendering deep in the body therefore has its stylesheet discovered late, andemit_route_style_preloadsexists to hoist that discovery into the head for every tag in the entry + route-chain closure not yet written. Bundling changes how many files exist, not where in the document they are referenced, so the late-discovery problem survives it completely. Two changes are needed:document_style_resourcescomponent tags.Alternatives or workarounds
CssStrategy::Bundle— the original framing of this issue, and wrong: it conflates grouping with delivery and cannot expressmodule+ grouped, which is the configuration actually measured.