Skip to content

feat(webpack-plugin): unstable_layeredOutput for chunkable CSS via @layer#851

Draft
layershifter wants to merge 13 commits into
microsoft:mainfrom
layershifter:users/olfedias/feat/css-chunking-design
Draft

feat(webpack-plugin): unstable_layeredOutput for chunkable CSS via @layer#851
layershifter wants to merge 13 commits into
microsoft:mainfrom
layershifter:users/olfedias/feat/css-chunking-design

Conversation

@layershifter
Copy link
Copy Markdown
Member

Summary

Adds an opt-in GriffelPlugin({ unstable_layeredOutput: true }) that lets webpack's default SplitChunksPlugin chunk Griffel-extracted CSS into multiple files without breaking cascade order.

When the flag is on, every emitted atomic rule is wrapped in a CSS Cascade Layer (@layer griffel.<bucket>[.s<priority>][.q<N>] { … }), and the plugin prepends a build-time-discovered global layer manifest to every griffel-bearing CSS asset. Cross-chunk LVHA, shorthand→longhand priority, and overlapping @media breakpoints all resolve via layer order — independent of which CSS file the browser parses first.

When the flag is off, behavior is byte-identical to today (verified by 23 fixture-based inline-snapshot tests passing without regeneration).

Architecture

  • @griffel/core: new opt-in BucketStrategy = 'leading' | 'extended'. 'extended' reclassifies nested-pseudo selectors (e.g. & .foo:hover) into their pseudo bucket so layer order doesn't defeat their specificity. Plumbed through resolveStyleRules and resolveStyleRulesForSlots.
  • @griffel/transform: transformSync accepts and forwards bucketStrategy.
  • @griffel/webpack-plugin:
    • New helpers: bucketLayerName, mediaPlaceholder, containerPlaceholder, hashOfQuery.
    • generateCSSRules({ wrapInLayer: true }) wraps each @griffel:css-start … @griffel:css-end block in @layer …. Marker comments stay outside the wrapper so parseCSSRules continues to find them at the top level.
    • Loader plumbs bucketStrategy and wrapInLayer from the loader context.
    • GriffelPlugin adds unstable_layeredOutput?: boolean. When on: skips the forced 'griffel' SplitChunks cache group + chunk-merge fallback; sets the loader-context flags; throws on combo with unstable_attachToEntryPoint.
    • processAssets runs a multi-chunk pass: walks all griffel-bearing CSS assets, aggregates the union set of @media and @container queries, sorts via compareMediaQueries, builds a global @layer ...; manifest, substitutes hash placeholders for q<N> indices, prepends the manifest to every asset.
  • Runtime DOM renderer: untouched. No @layer at runtime; SSR rehydration unchanged.

Spec: docs/superpowers/specs/2026-04-25-griffel-css-chunking-design.md
Repro: apps/chunking-repro/ — three modes (default, split, layered) for side-by-side inspection.

Trade-offs / known limitations

  • Layered rules lose to unlayered consumer / third-party CSS by CSS spec. Recommendation in spec: wrap consumer styles in their own layer. Out of scope here.
  • Mixed extraction + runtime caveat: if an app extracts some styles (layered) and renders others at runtime, cascade for that mixed scenario is undefined. Most apps either fully extract or don't extract at all.
  • Browser support: @layer requires Chrome 99+ / Safari 15.4+ / Firefox 97+ (released early 2022). Apps with older baselines should leave the flag off.
  • Bucket t (@layer / @supports): not split into per-rule sub-layers in V1.

Test plan

  • @griffel/core:test — 432 tests pass
  • @griffel/transform:test — 80 tests pass
  • @griffel/webpack-plugin:test — 59 tests pass (incl. 4 new unstable_layeredOutput tests covering manifest emission, layered output for nested-pseudo + priority shorthands, mutual exclusion with unstable_attachToEntryPoint)
  • @griffel/webpack-plugin:type-check — clean
  • Existing 23 inline-snapshot fixture tests pass without regeneration (byte-identical default-off path)
  • e2e/rspack snapshot unchanged
  • End-to-end: apps/chunking-repro builds in all three modes; layered mode produces 3 CSS files each starting with the global manifest, no placeholders remaining

Migration

This ships as unstable_layeredOutput for one release cycle. Once feedback settles (especially the unlayered-vs-layered consumer trade-off and the mixed-mode caveat), promote to a stable layeredOutput option.

🤖 Generated with Claude Code

layershifter and others added 13 commits April 25, 2026 10:43
Adds a design doc for chunking the extracted Griffel CSS via opt-in
CSS Cascade Layers (`unstable_layeredOutput`), plus a small
`apps/chunking-repro` app that demonstrates the cross-chunk cascade
ordering problem the design addresses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10-task TDD plan for delivering unstable_layeredOutput, broken into
1) bucket reclassification in @griffel/core (gated),
2) bucketStrategy plumb through @griffel/transform,
3) loader-time @layer wrapping in @griffel/webpack-plugin,
4) asset-time global manifest aggregation across chunks, and
5) a layered build mode in apps/chunking-repro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional strategy parameter to getStyleBucketName. The new
"extended" strategy walks the full selector and bucketizes by the last
LVHA pseudo found anywhere, instead of only the leading character.
Adds an options parameter to resolveStyleRules and forwards
bucketStrategy to getStyleBucketName from every call site, including
recursive descents into nested selectors and at-rules.
Threads ResolveStyleRulesOptions through resolveStyleRulesForSlots
and resolveResetStyleRules so build-time callers can opt into the
extended bucket strategy.
Forwards a new bucketStrategy option to resolveStyleRulesForSlots
so callers of @griffel/transform can extract atomic CSS using the
extended bucket assignment.
Pure helpers used by the loader to emit @layer wrappers and by the
plugin's processAssets pass to substitute media/container placeholders.
Adds a wrapInLayer mode to generateCSSRules. Each block between
@Griffel:css-start / @Griffel:css-end markers is wrapped in
@layer griffel.<bucket>[.s<priority>][.<placeholder>] { ... }, with
marker comments staying outside the wrapper so the asset-time parser
continues to find them at the top level.
Reads the layered-output flags from the loader context and forwards
them to transformSync and generateCSSRules.
Adds the opt-in flag, plumbs it to the loader context, and skips the
forced 'griffel' SplitChunks cache group + chunk-merge fallback when
the flag is on. Throws when combined with unstable_attachToEntryPoint.
Adds a processAssets pass that, when unstable_layeredOutput is on,
walks every griffel-bearing CSS asset, aggregates the union set of
@media / @container queries, sorts them with compareMediaQueries,
emits a global manifest at the top of every asset, and substitutes
hash placeholders with indexed q<N> layer names.

Test helper: Option A (extend compileSourceWithWebpack with entryMap?
in TestOptions) so existing tests are unaffected. Added readAsset()
to the return value. New fixture: __fixtures__/layered-multi-entry/
with page-a.ts (800px), page-b.ts (1200px), and shared.ts.

Note: 'c' bucket is excluded from the static-bucket assertion because
@griffel/core does not currently emit 'c' metadata for container
queries, so no __griffelcq_ placeholder is generated and the bucket
name is emitted verbatim as griffel.c (handled by bucketLayerName).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires the repro to the new unstable_layeredOutput plugin option so
the layered output can be inspected side-by-side with the default
and split modes.
…kets

STATIC_BUCKET_LAYERS only declared priority sub-layers for bucket 'd'.
Any selector-driven bucket can carry shorthand priority (e.g.
':hover': { padding: ... } → bucket 'h', priority -1), so the manifest
must declare s-2/s-1 sub-layers for d, l, v, w, f, i, h, a.

Without this, sub-layer ordering across chunks becomes
implementation-defined and reintroduces the cascade nondeterminism
the layered design exists to solve.

Also corrects the spec to match the implementation's hash handling
(no build-time collision detection; relies on 32-bit entropy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant