From e28534c7cd0849523d17a1d6a28c454773c20492 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Sat, 25 Apr 2026 10:43:29 +0200 Subject: [PATCH 01/13] docs(webpack-plugin): chunking design + chunking-repro app 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) --- apps/chunking-repro/README.md | 86 +++++ apps/chunking-repro/babel.config.cjs | 7 + apps/chunking-repro/build.mjs | 144 ++++++++ apps/chunking-repro/package.json | 5 + apps/chunking-repro/project.json | 33 ++ apps/chunking-repro/public/page-a.html | 16 + apps/chunking-repro/public/page-b.html | 16 + apps/chunking-repro/serve.mjs | 41 +++ apps/chunking-repro/src/page-a.tsx | 21 ++ apps/chunking-repro/src/page-b.tsx | 21 ++ apps/chunking-repro/src/styles/page-a.ts | 17 + apps/chunking-repro/src/styles/page-b.ts | 21 ++ apps/chunking-repro/src/styles/shared.ts | 16 + apps/chunking-repro/tsconfig.json | 12 + .../2026-04-25-griffel-css-chunking-design.md | 347 ++++++++++++++++++ 15 files changed, 803 insertions(+) create mode 100644 apps/chunking-repro/README.md create mode 100644 apps/chunking-repro/babel.config.cjs create mode 100644 apps/chunking-repro/build.mjs create mode 100644 apps/chunking-repro/package.json create mode 100644 apps/chunking-repro/project.json create mode 100644 apps/chunking-repro/public/page-a.html create mode 100644 apps/chunking-repro/public/page-b.html create mode 100644 apps/chunking-repro/serve.mjs create mode 100644 apps/chunking-repro/src/page-a.tsx create mode 100644 apps/chunking-repro/src/page-b.tsx create mode 100644 apps/chunking-repro/src/styles/page-a.ts create mode 100644 apps/chunking-repro/src/styles/page-b.ts create mode 100644 apps/chunking-repro/src/styles/shared.ts create mode 100644 apps/chunking-repro/tsconfig.json create mode 100644 docs/superpowers/specs/2026-04-25-griffel-css-chunking-design.md diff --git a/apps/chunking-repro/README.md b/apps/chunking-repro/README.md new file mode 100644 index 000000000..eca008435 --- /dev/null +++ b/apps/chunking-repro/README.md @@ -0,0 +1,86 @@ +# Griffel chunking repro + +A minimal app that demonstrates why `@griffel/webpack-plugin` currently +forces all extracted CSS into a single `griffel.css` chunk and what +breaks if it doesn't. + +## Layout + +- `src/page-a.tsx`, `src/page-b.tsx` — two webpack entry points. +- `src/styles/shared.ts` — atomic styles imported by both pages + (lives in a shared chunk under default `SplitChunksPlugin`). +- `src/styles/page-a.ts`, `src/styles/page-b.ts` — per-page atomic styles + using buckets `d` (default), `h` (hover), and `m` (`@media`). + Page B also uses a shorthand (`gap`) + a longhand override (`rowGap`) + to exercise priority ordering. + +## Build + +```sh +# Default mode: GriffelPlugin's single-chunk forcing is on (current behavior). +node build.mjs + +# Split mode: a tiny "DisableGriffelChunkMergePlugin" runs after GriffelPlugin +# and removes the forced 'griffel' SplitChunks cache group, letting webpack +# place each .griffel.css module in whichever chunk discovers it first. +node build.mjs --split +``` + +Outputs land under `dist/apps/chunking-repro/{default,split}/`. + +## What you see + +### Default mode (`dist/apps/chunking-repro/default/griffel.css`) + +One file, rules sorted globally by `(media, bucket, priority)`: + +```css +.f1t5xh8k{border:1px solid black;} /* d, p=-2 (shorthand) */ +.f19gb1f4{gap:8px;} /* d, p=-1 (shorthand) */ +.f1sy4kr4{padding:12px;} /* d, p=-1 (shorthand) */ +.fe3e8s9{color:red;} /* d, p=0 (longhand) */ +.fka9v86{color:green;} +.ftuwxu6{display:inline-flex;} +.f7qsgvn{row-gap:24px;} /* d, p=0 - wins over gap */ +.ftgm304{display:block;} +.f10q6zxg:hover{color:blue;} /* h */ +@media (min-width: 800px){.f92grqi{color:orange;}} /* m */ +``` + +LVHA + at-rule order is preserved; `rowGap` reliably overrides `gap`. + +### Split mode (`dist/apps/chunking-repro/split/`) + +Three files: `226.css` (shared), `page-a.css`, `page-b.css`. +Each file preserves its own block-level structure but no cross-file +sort runs (the plugin sorts only the named `griffel` chunk that no +longer exists). Concretely, `226.css` ships `display:block` +*before* `padding:12px` *before* `border:1px solid black` — i.e. +**inverse of the priority order**. And page-b's `gap` (priority -1) +ships earlier in the same file as `rowGap` (priority 0), which works +in this contrived case but only because they happen to land in the +same module. + +The actual cascade now depends on browser `` evaluation order: + +- If `226.css` loads AFTER `page-a.css`, page-a's `color:red` (bucket + `d`) appears in the cascade BEFORE 226's later rules — fine here, but + in any other authoring `:hover` (bucket `h`) emitted in `page-a.css` + could be defeated by a later same-property base rule from `226.css`. +- The same applies across `@media` and shorthand priorities: a + cross-chunk longhand cannot count on appearing after its shorthand. +- Hover and `@media` rules duplicate across `page-a.css` and + `page-b.css` (same class hashes), inflating CSS for free. + +This is what motivates moving away from the single-chunk constraint +without giving up cascade correctness. + +## Serve it locally + +```sh +node build.mjs && node serve.mjs # default mode +node build.mjs --split && node serve.mjs --split # broken mode +``` + +Then open `http://localhost:3000/page-a.html` and +`http://localhost:3000/page-b.html`. diff --git a/apps/chunking-repro/babel.config.cjs b/apps/chunking-repro/babel.config.cjs new file mode 100644 index 000000000..56943edbb --- /dev/null +++ b/apps/chunking-repro/babel.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { esmodules: true } }], + ['@babel/preset-react', { runtime: 'classic' }], + '@babel/preset-typescript', + ], +}; diff --git a/apps/chunking-repro/build.mjs b/apps/chunking-repro/build.mjs new file mode 100644 index 000000000..45f21c768 --- /dev/null +++ b/apps/chunking-repro/build.mjs @@ -0,0 +1,144 @@ +// Builds the repro in either "default" mode (current behavior, single griffel.css) +// or "split" mode (the broken multi-chunk emission we are diagnosing). +// +// Usage: +// node build.mjs # default mode +// node build.mjs --split # split mode (disable plugin's single-chunk forcing) +// +// Outputs to ../../dist/apps/chunking-repro// +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +import webpack from 'webpack'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; + +import { GriffelPlugin } from '../../dist/packages/webpack-plugin/src/index.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..', '..'); +const split = process.argv.includes('--split'); +const outDir = path.resolve(rootDir, 'dist/apps/chunking-repro', split ? 'split' : 'default'); + +// Plugin that runs AFTER GriffelPlugin and removes its forced 'griffel' +// SplitChunks cache group, so griffel CSS modules fall into each chunk +// naturally - exposing the cross-chunk cascade ordering bug. +class DisableGriffelChunkMergePlugin { + apply(compiler) { + const sc = compiler.options.optimization.splitChunks; + if (sc && sc.cacheGroups && sc.cacheGroups.griffel) { + delete sc.cacheGroups.griffel; + } + } +} + +const distWebpackPlugin = path.resolve(rootDir, 'dist/packages/webpack-plugin'); + +const config = { + context: __dirname, + mode: 'production', + devtool: false, + entry: { + 'page-a': './src/page-a.tsx', + 'page-b': './src/page-b.tsx', + }, + output: { + path: outDir, + filename: '[name].bundle.js', + chunkFilename: '[name].chunk.js', + clean: true, + }, + resolve: { + extensions: ['.tsx', '.ts', '.mjs', '.js'], + alias: { + // Map @griffel package names to the BUILT dist (root node_modules + // points to source which has only .ts/.mts files). + '@griffel/webpack-plugin$': path.join(distWebpackPlugin, 'src/index.mjs'), + '@griffel/react$': path.resolve(rootDir, 'dist/packages/react/src/index.js'), + '@griffel/core$': path.resolve(rootDir, 'dist/packages/core/src/index.js'), + '@griffel/style-types$': path.resolve(rootDir, 'dist/packages/style-types/src/index.js'), + }, + }, + resolveLoader: { + alias: { + '@griffel/webpack-plugin/loader': path.join(distWebpackPlugin, 'src/webpackLoader.mjs'), + }, + }, + module: { + rules: [ + // Babel transforms TS/JSX everywhere we care about (app src + @griffel react JSX). + { + test: /\.(tsx|ts|jsx)$/, + include: [path.resolve(__dirname, 'src')], + use: [ + { loader: 'babel-loader', options: { configFile: path.resolve(__dirname, 'babel.config.cjs') } }, + { loader: '@griffel/webpack-plugin/loader' }, + ], + }, + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + optimization: { + minimize: false, + // Default behavior: vendors group + 'all' chunk so shared modules + // (here: ./styles/shared.ts via 2 entries) hoist into a common chunk. + splitChunks: { + chunks: 'all', + minSize: 0, + cacheGroups: { + defaultVendors: false, + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true, + }, + }, + }, + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].css', + chunkFilename: '[name].chunk.css', + }), + new GriffelPlugin(), + ...(split ? [new DisableGriffelChunkMergePlugin()] : []), + new HtmlWebpackPlugin({ + filename: 'page-a.html', + template: path.resolve(__dirname, 'public/page-a.html'), + chunks: ['page-a'], + inject: false, + }), + new HtmlWebpackPlugin({ + filename: 'page-b.html', + template: path.resolve(__dirname, 'public/page-b.html'), + chunks: ['page-b'], + inject: false, + }), + ], + performance: { hints: false }, + stats: 'minimal', +}; + +webpack(config, (err, stats) => { + if (err) { + console.error(err); + process.exit(1); + } + console.log(stats.toString({ colors: true, chunks: true, chunkModules: false, assets: true })); + if (stats.hasErrors()) process.exit(1); + + console.log(`\nMode: ${split ? 'SPLIT (broken)' : 'DEFAULT (single griffel.css)'}`); + console.log(`Output: ${path.relative(rootDir, outDir)}`); + + // List CSS assets + const cssFiles = fs.readdirSync(outDir).filter(f => f.endsWith('.css')); + console.log(`\nCSS files emitted:`); + for (const file of cssFiles) { + const size = fs.statSync(path.join(outDir, file)).size; + console.log(` ${file} (${size} bytes)`); + } +}); diff --git a/apps/chunking-repro/package.json b/apps/chunking-repro/package.json new file mode 100644 index 000000000..5e27ca64d --- /dev/null +++ b/apps/chunking-repro/package.json @@ -0,0 +1,5 @@ +{ + "name": "@griffel/chunking-repro", + "private": true, + "type": "module" +} diff --git a/apps/chunking-repro/project.json b/apps/chunking-repro/project.json new file mode 100644 index 000000000..eb74fd2f3 --- /dev/null +++ b/apps/chunking-repro/project.json @@ -0,0 +1,33 @@ +{ + "name": "@griffel/chunking-repro", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/chunking-repro/src", + "projectType": "application", + "implicitDependencies": ["@griffel/webpack-plugin", "@griffel/react"], + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/chunking-repro", + "commands": [{ "command": "node ./build.mjs" }] + }, + "outputs": ["{workspaceRoot}/dist/apps/chunking-repro"] + }, + "build:split": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/chunking-repro", + "commands": [{ "command": "node ./build.mjs --split" }] + }, + "outputs": ["{workspaceRoot}/dist/apps/chunking-repro"] + }, + "serve": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/chunking-repro", + "commands": [{ "command": "node ./serve.mjs" }] + } + } + }, + "tags": [] +} diff --git a/apps/chunking-repro/public/page-a.html b/apps/chunking-repro/public/page-a.html new file mode 100644 index 000000000..67ee8b4e0 --- /dev/null +++ b/apps/chunking-repro/public/page-a.html @@ -0,0 +1,16 @@ + + + + + Chunking repro - Page A + + + <% for (var f of htmlWebpackPlugin.files.css || []) { %> + + <% } %> +
+ <% for (var f of htmlWebpackPlugin.files.js || []) { %> + + <% } %> + + diff --git a/apps/chunking-repro/public/page-b.html b/apps/chunking-repro/public/page-b.html new file mode 100644 index 000000000..1ea696717 --- /dev/null +++ b/apps/chunking-repro/public/page-b.html @@ -0,0 +1,16 @@ + + + + + Chunking repro - Page B + + + <% for (var f of htmlWebpackPlugin.files.css || []) { %> + + <% } %> +
+ <% for (var f of htmlWebpackPlugin.files.js || []) { %> + + <% } %> + + diff --git a/apps/chunking-repro/serve.mjs b/apps/chunking-repro/serve.mjs new file mode 100644 index 000000000..b436efad5 --- /dev/null +++ b/apps/chunking-repro/serve.mjs @@ -0,0 +1,41 @@ +// Serves the built dist over HTTP so the cascade behavior can be inspected +// in a browser. Pass --split to serve the split-mode build. +import * as http from 'node:http'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const split = process.argv.includes('--split'); +const root = path.resolve(__dirname, '..', '..', 'dist/apps/chunking-repro', split ? 'split' : 'default'); +const port = Number(process.env.PORT) || 3000; + +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', +}; + +http + .createServer((req, res) => { + const url = req.url === '/' ? '/page-a.html' : req.url.split('?')[0]; + const filePath = path.join(root, url); + if (!filePath.startsWith(root)) { + res.writeHead(403); + res.end('forbidden'); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': types[path.extname(filePath)] || 'application/octet-stream' }); + res.end(data); + }); + }) + .listen(port, () => { + console.log(`Serving ${path.relative(process.cwd(), root)} at http://localhost:${port}/`); + console.log(`Pages: http://localhost:${port}/page-a.html http://localhost:${port}/page-b.html`); + }); diff --git a/apps/chunking-repro/src/page-a.tsx b/apps/chunking-repro/src/page-a.tsx new file mode 100644 index 000000000..e74f91f4d --- /dev/null +++ b/apps/chunking-repro/src/page-a.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { mergeClasses } from '@griffel/react'; +import { usePageAStyles } from './styles/page-a'; +import { useSharedStyles } from './styles/shared'; + +function App() { + const styles = usePageAStyles(); + const shared = useSharedStyles(); + return ( +
+

Page A

+ +

Default color: red. Hover: blue. @media (min-width: 800px): orange.

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/apps/chunking-repro/src/page-b.tsx b/apps/chunking-repro/src/page-b.tsx new file mode 100644 index 000000000..0379e9e3a --- /dev/null +++ b/apps/chunking-repro/src/page-b.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { mergeClasses } from '@griffel/react'; +import { usePageBStyles } from './styles/page-b'; +import { useSharedStyles } from './styles/shared'; + +function App() { + const styles = usePageBStyles(); + const shared = useSharedStyles(); + return ( +
+

Page B

+ +

Default color: green. border + borderColor. Hover: blue. @media: orange.

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/apps/chunking-repro/src/styles/page-a.ts b/apps/chunking-repro/src/styles/page-a.ts new file mode 100644 index 000000000..4149a254a --- /dev/null +++ b/apps/chunking-repro/src/styles/page-a.ts @@ -0,0 +1,17 @@ +import { makeStyles } from '@griffel/react'; + +// Page A's button. +// +// - Default color in bucket 'd' (catch-all). +// - Hover color in bucket 'h' (LVHA: must come AFTER bucket 'd' in the cascade). +// - Wide-screen color in bucket 'm' (@media; must come AFTER non-media buckets). +// +// All three rules use single-class selectors -> specificity is identical, so the +// cascade resolves them by source order alone. +export const usePageAStyles = makeStyles({ + button: { + color: 'red', + ':hover': { color: 'blue' }, + '@media (min-width: 800px)': { color: 'orange' }, + }, +}); diff --git a/apps/chunking-repro/src/styles/page-b.ts b/apps/chunking-repro/src/styles/page-b.ts new file mode 100644 index 000000000..a42a0c286 --- /dev/null +++ b/apps/chunking-repro/src/styles/page-b.ts @@ -0,0 +1,21 @@ +import { makeStyles } from '@griffel/react'; + +// Page B's button. Different default color than Page A, but the SAME +// hover and SAME @media wide-screen color. +// +// Page B also uses the `gap` shorthand (priority -1) plus a `rowGap` +// longhand (priority 0). The plugin sorts by priority within bucket 'd' +// so that the shorthand emits BEFORE the longhand, ensuring the longhand +// "wins" in the cascade. If the plugin emits two separate chunks +// instead, that priority is enforced only by source order across files, +// which is unstable. +export const usePageBStyles = makeStyles({ + button: { + color: 'green', + display: 'inline-flex', + gap: '8px', + rowGap: '24px', + ':hover': { color: 'blue' }, + '@media (min-width: 800px)': { color: 'orange' }, + }, +}); diff --git a/apps/chunking-repro/src/styles/shared.ts b/apps/chunking-repro/src/styles/shared.ts new file mode 100644 index 000000000..2e04cc14a --- /dev/null +++ b/apps/chunking-repro/src/styles/shared.ts @@ -0,0 +1,16 @@ +import { makeStyles } from '@griffel/react'; + +// "Shared" styles imported by both pages. +// +// In default SplitChunksPlugin behavior, when a module is imported by 2+ +// chunks it is hoisted into a separate chunk. So this module's atomic +// rules will end up in a different .css file from the per-page rules. +// +// Atomic rules here all live in bucket 'd' (catch-all). +export const useSharedStyles = makeStyles({ + shell: { + display: 'block', + padding: '12px', + border: '1px solid black', + }, +}); diff --git a/apps/chunking-repro/tsconfig.json b/apps/chunking-repro/tsconfig.json new file mode 100644 index 000000000..8e120b135 --- /dev/null +++ b/apps/chunking-repro/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react", + "esModuleInterop": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/docs/superpowers/specs/2026-04-25-griffel-css-chunking-design.md b/docs/superpowers/specs/2026-04-25-griffel-css-chunking-design.md new file mode 100644 index 000000000..1a583ce04 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-griffel-css-chunking-design.md @@ -0,0 +1,347 @@ +# Griffel CSS chunking via cascade layers + +**Status:** Draft +**Date:** 2026-04-25 +**Repro:** `apps/chunking-repro/` + +## Problem + +`@griffel/webpack-plugin` today forces every `.griffel.css` module into a +single SplitChunks cache group named `'griffel'` (`enforce: true`, +`chunks: 'all'`). After `mini-css-extract-plugin` concatenates them the +plugin re-parses the merged asset and globally sorts rules by +`(media, bucket, priority)` across the 14-bucket scheme defined in +`@griffel/core` (`r, d, l, v, w, f, i, h, a, s, k, t, m, c`). + +The single chunk is required because Griffel atomic rules use +single-class selectors. Specificity between any two atomic rules is +identical, so the cascade resolves them by **source order** alone. As +soon as those rules live in two or more CSS files, source order is +governed by browser `` evaluation order — which is not guaranteed +by webpack across preload, prefetch, route splits, or arbitrary load +graphs. + +The repro under `apps/chunking-repro/` makes this concrete. Two webpack +entries plus a shared module compile in two modes: + +- **Default** — single `griffel.css` (current behavior). Rules sorted + globally; LVHA + shorthand→longhand priority correct. +- **Split** (a six-line `DisableGriffelChunkMergePlugin` deletes the + forced cache group) — three CSS files. `226.css` ships + `display:block` → `padding:12px` (p=−1) → `border:1px solid black` + (p=−2), inverse of the priority order, and `:hover` / `@media` rules + duplicate across `page-a.css` / `page-b.css`. + +We want the default `SplitChunksPlugin` chunking behavior to apply to +griffel CSS too, without giving up cascade correctness. + +## Goal + +Allow webpack's default `SplitChunksPlugin` to chunk `.griffel.css` +modules naturally (vendors group, async splits, shared-module hoisting) +while preserving deterministic cascade ordering across files. + +Out of scope: + +- Module Federation / independently-built bundles. +- Mitigating Griffel's "atomic rules lose to consumer unlayered CSS" + trade-off (the user's prior concerns A/B). Documented; not addressed. +- Changing runtime DOM rendering behavior. + +## Approach: CSS Cascade Layers, opt-in + +Wrap each emitted atomic rule in a CSS `@layer` whose name encodes the +rule's `(bucket, priority [, media-query-index])`. Declare layer order +once at the top of every emitted griffel CSS chunk. Layer order takes +precedence over source order in the CSS cascade, which removes the +file-load-order dependency. + +This is gated behind a new plugin option, +`GriffelPlugin({ unstable_layeredOutput: true })`. When the flag is +disabled the plugin behaves exactly as it does today. + +### Layer scheme + +A single `@layer` declaration prepended to every griffel CSS asset: + +```css +@layer + griffel.r, + griffel.d.s-2, griffel.d.s-1, griffel.d, + griffel.l, griffel.v, griffel.w, griffel.f, griffel.i, griffel.h, griffel.a, + griffel.s, griffel.k, griffel.t, + griffel.m.q0, griffel.m.q1, /* ... one entry per discovered @media query, in compareMediaQueries order ... */ + griffel.c.q0, griffel.c.q1; /* ... one entry per discovered @container query ... */ +``` + +Each emitted rule is wrapped in its matching layer. The +`@griffel:css-start` / `@griffel:css-end` marker comments stay **outside** +the layer wrapper so the existing top-level parser in +`parseCSSRules` can still find them: + +```css +/** @griffel:css-start [d] {"p":-2} **/ +@layer griffel.d.s-2 { .f1abc{border:2px solid red} } +/** @griffel:css-end **/ + +/** @griffel:css-start [d] null **/ +@layer griffel.d { .f2xyz{color:red} } +/** @griffel:css-end **/ + +/** @griffel:css-start [h] null **/ +@layer griffel.h { .f3:hover{color:blue} } +/** @griffel:css-end **/ + +/** @griffel:css-start [m] {"m":"(min-width: 800px)"} **/ +@layer griffel.m.q1 { @media (min-width: 800px){.f4{color:orange}} } +/** @griffel:css-end **/ +``` + +CSS layer ordering is set by the **first** declaration the browser +encounters; subsequent identical declarations are no-ops. Because every +emitted griffel chunk repeats the same manifest, whichever chunk loads +first establishes a deterministic order across all chunks. + +### Bucket reclassification (gated) + +`getStyleBucketName` today bases the bucket on the leading character of +`selectors[0]`. Selectors that don't start with a pseudo (e.g. +`'& .foo:hover'`, `'&.disabled:hover'`) currently fall into bucket `d` +and rely on **selector specificity** (multiple classes ⇒ ≥ (0,2,1)) to +beat plain `.f1:hover` (0,1,1). Once we wrap by bucket, layer order +trumps specificity — so a plain `:hover` in `griffel.h` would defeat a +nested `& .foo:hover` in `griffel.d`. That is a regression. + +Fix: when the layered output strategy is in effect, reclassify rules by +the **last LVHA pseudo found anywhere in the selector**, not just at the +start. Plain `.f1:hover` and nested `.f2 .foo:hover` then both live in +`griffel.h`, and within that single layer specificity continues to do +its job (nested still beats plain). + +The reclassification is an opt-in strategy, plumbed from the plugin +through the loader → `transformSync` → `resolveStyleRules` → +`getStyleBucketName`. Default behavior (no flag) is unchanged. + +### Priority via sub-layers + +`computePropertyPriority` assigns each rule a priority from a bounded +set defined by `shorthands.ts` (`-2, -1, 0`). The shorthand-before- +longhand source order is currently emergent from the global sort; once +chunks split, priority is no longer enforced cross-file. + +For each non-media bucket that allows priority (today: `d`), declare a +sub-layer per priority value (`griffel.d.s-2`, `griffel.d.s-1`, +`griffel.d`). Wrap each rule in its priority sub-layer. Layer order +encodes priority deterministically across chunks. + +### Media and container queries via build-time-discovered sub-layers + +The plugin scans every griffel-bearing CSS asset in the compilation +during `processAssets`, collects the union set of `@media` and +`@container` queries, sorts each set with the configured +`compareMediaQueries` (and the analogous container query comparator), +and assigns each query a stable index. The sorted indices appear in the +manifest as `griffel.m.q0`, `griffel.m.q1`, … and +`griffel.c.q0`, `griffel.c.q1`, …. Each rule references the index for +its specific query. + +This treats overlapping media queries deterministically — e.g. on a +1500px viewport, an `@media (min-width: 800px)` rule from one chunk and +an `@media (min-width: 1200px)` rule from another resolve via layer +order rather than file load order. + +The query index is derived from a deterministic build-wide sort, so +unchanged inputs produce unchanged output. New queries introduced by a +later edit may shift indices; that is acceptable since the manifest is +rewritten on every build. + +The loader does not know the global index of a query at module-loader +time. It emits a deterministic placeholder layer name derived from a +short, stable hash of the query string. The placeholder is a valid CSS +ident: + +- Format: `griffel.m.__griffelmq___` +- Hash: first 8 hex chars of the same string hash already used by + `@griffel/core` for class names (`hashString` in + `@emotion/hash`), keyed off the query text. +- Container queries use the same scheme with prefix `__griffelcq_`. + +The plugin's `processAssets` pass: +1. Reads each asset's `@griffel:css-start [m] {"m":""}` markers, + collects the union set of `` strings. +2. Sorts the set with `compareMediaQueries`, assigns each query an + index `q0`, `q1`, …. +3. Computes the same hash for each query and builds a + `hash → q` mapping. +4. Substitutes `__griffelmq___` → `q` (and the analogous + container query mapping) in every asset's CSS source. + +Hash collisions are handled by widening the hash window (e.g. fall +back to 12 hex chars) the first time a collision is detected at build +time. Collisions on 8 hex chars are negligibly rare in practice. + +### Bucket `t` (`@layer` / `@supports`) and bucket `r` + +These buckets are not split into sub-layers in V1. + +- Bucket `r` (reset rules) — keyed by reset class hash; rules across + chunks are independent (each reset class is a distinct identifier). + No cross-chunk overlap on the same property under the same selector. + A single `griffel.r` layer is sufficient. +- Bucket `t` (`@layer` / `@supports`) — user-authored at-rules. User + `@layer` and `@supports` carry their own cascade semantics; layering + griffel content under a `griffel.t` layer is sufficient and overlap + scenarios are not common atomic-CSS authoring patterns. + +If real cross-chunk overlap problems emerge for these buckets, sub- +layer treatment can be extended in a follow-up. + +### What the plugin does at build time + +1. **Loader pass** — when `unstable_layeredOutput` is on, the loader + passes `bucketStrategy: 'extended'` to `transformSync`, then for each + `/** @griffel:css-start [bucket] {meta} **/ ... /** @griffel:css-end **/` + block emitted by the existing pipeline, wraps the rules between the + markers in `@layer griffel.[.s] { ... }`. Media + and container query rules use a hashed placeholder layer name (see + above). The marker comments themselves stay **outside** the layer + wrapper so `parseCSSRules` continues to find them at the top level + of the asset. +2. **`processAssets` pass** — for every asset that contains + `/** @griffel:css-start`: + - Parse with the existing `parseCSSRules`. + - Aggregate the union of media / container queries across assets. + - Sort with `compareMediaQueries`; assign each query a `q` index. + - Build the global manifest declaration. + - For each asset: prepend the manifest, swap placeholder layer names + for indexed ones, locally sort rules within the file (defensive, + to keep file-internal output stable across builds — correctness + does not depend on it). +3. **SplitChunks** — when `unstable_layeredOutput` is on, the plugin + does NOT inject the forced `'griffel'` cache group and does NOT + register the `moveCSSModulesToGriffelChunk` fallback. Webpack's + default `SplitChunksPlugin` chunks `.griffel.css` modules naturally. +4. **`unstable_attachToEntryPoint`** — incompatible with + `unstable_layeredOutput` (single-chunk semantics). The plugin throws + if both are set. + +### What does NOT change + +- Runtime DOM renderer (`createDOMRenderer`, + `getStyleSheetForBucket`, `rehydrateRendererCache`). No `@layer` + emitted at runtime; `