Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions apps/chunking-repro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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

# Layered mode: GriffelPlugin runs with `unstable_layeredOutput: true`, so
# every emitted .griffel.css module is wrapped in @layer and a global layer
# manifest is prepended to every chunk's CSS asset.
node build.mjs --layered
```

Outputs land under `dist/apps/chunking-repro/{default,split,layered}/`.

## 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 `<link>` 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.

### Layered mode (`dist/apps/chunking-repro/layered/`)

Three (or more) CSS files. Each one begins with the same global manifest:

```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, griffel.c.q0;
```

The exact set of `griffel.m.q*` and `griffel.c.q*` layers depends on
which `@media` and `@container` queries the bundle uses; this fixture
only emits `griffel.m.q0` since both pages share the same breakpoint
and have no container queries.

Individual rules are wrapped in their layer (`@layer griffel.h { … }`,
`@layer griffel.m.q0 { @media (...) { … } }`). LVHA, shorthand→longhand
priority, and overlapping `@media` breakpoints all resolve via layer
order — independent of which CSS file the browser parses first.

## Serve it locally

```sh
node build.mjs && node serve.mjs # default mode
node build.mjs --split && node serve.mjs --split # broken mode
node build.mjs --layered && node serve.mjs --layered # layered mode
```

Then open `http://localhost:3000/page-a.html` and
`http://localhost:3000/page-b.html`.
7 changes: 7 additions & 0 deletions apps/chunking-repro/babel.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { esmodules: true } }],
['@babel/preset-react', { runtime: 'classic' }],
'@babel/preset-typescript',
],
};
150 changes: 150 additions & 0 deletions apps/chunking-repro/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Builds the repro in one of three modes:
// "default" — current behavior, single griffel.css
// "split" — broken multi-chunk emission (diagnostic)
// "layered" — unstable_layeredOutput: true, @layer wrappers + manifest
//
// Usage:
// node build.mjs # default mode
// node build.mjs --split # split mode (disable plugin's single-chunk forcing)
// node build.mjs --layered # layered mode (unstable_layeredOutput: true)
//
// Outputs to ../../dist/apps/chunking-repro/<mode>/
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 layered = process.argv.includes('--layered');
const split = !layered && process.argv.includes('--split');

const mode = layered ? 'layered' : split ? 'split' : 'default';
const outDir = path.resolve(rootDir, 'dist/apps/chunking-repro', mode);

// 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({ unstable_layeredOutput: layered }),
...(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: ${mode.toUpperCase()}`);
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)`);
}
});
5 changes: 5 additions & 0 deletions apps/chunking-repro/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@griffel/chunking-repro",
"private": true,
"type": "module"
}
41 changes: 41 additions & 0 deletions apps/chunking-repro/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"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"]
},
"build:layered": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/chunking-repro",
"commands": [{ "command": "node ./build.mjs --layered" }]
},
"outputs": ["{workspaceRoot}/dist/apps/chunking-repro"]
},
"serve": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/chunking-repro",
"commands": [{ "command": "node ./serve.mjs" }]
}
}
},
"tags": []
}
16 changes: 16 additions & 0 deletions apps/chunking-repro/public/page-a.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Chunking repro - Page A</title>
</head>
<body>
<% for (var f of htmlWebpackPlugin.files.css || []) { %>
<link rel="stylesheet" href="<%= f %>" />
<% } %>
<div id="root"></div>
<% for (var f of htmlWebpackPlugin.files.js || []) { %>
<script src="<%= f %>"></script>
<% } %>
</body>
</html>
16 changes: 16 additions & 0 deletions apps/chunking-repro/public/page-b.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Chunking repro - Page B</title>
</head>
<body>
<% for (var f of htmlWebpackPlugin.files.css || []) { %>
<link rel="stylesheet" href="<%= f %>" />
<% } %>
<div id="root"></div>
<% for (var f of htmlWebpackPlugin.files.js || []) { %>
<script src="<%= f %>"></script>
<% } %>
</body>
</html>
44 changes: 44 additions & 0 deletions apps/chunking-repro/serve.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Serves the built dist over HTTP so the cascade behavior can be inspected
// in a browser. Pass --split to serve the split-mode build, or --layered
// to serve the layered-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 layered = process.argv.includes('--layered');
const split = !layered && process.argv.includes('--split');
const mode = layered ? 'layered' : split ? 'split' : 'default';
const root = path.resolve(__dirname, '..', '..', 'dist/apps/chunking-repro', mode);
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`);
});
Loading
Loading