Production CSS broken, dev fine: Tailwind v4, CSSO, and Astro compress
pnpm dev looked perfect. Six poster columns on a wide screen. pnpm build && pnpm preview showed two columns only — and so did the live site. No console errors. HTML still had sm:grid-cols-3 md:grid-cols-4 …. The classes were in the markup; the CSS rules were not.
This is a write-up of that rabbit hole: double CSS minification, a minifier that cannot parse modern media queries, an integration that ignores css: false, an ESM/require footgun, and a Cloudflare frozen-lockfile failure after the fix.
Symptoms
| Environment | Layout |
|---|---|
pnpm dev | Responsive grid (2 → 3 → 4 → 5 → 6 columns) |
pnpm build + pnpm preview | Always 2 columns |
| Production (Cloudflare) | Same as preview |
DOM snippet (simplified):
<div class="media-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">Computed style on a 1440px viewport in production:
grid-template-columns: 624px 624px /* only grid-cols-2 */In dev:
grid-template-columns: 198px × 6 /* xl:grid-cols-6 applied */No JS errors. No missing CSS file. Just missing utilities.
Pipeline: who touches CSS?
On this site (Astro 7 + Tailwind v4 via @tailwindcss/vite + gab-astro-compress):
Tailwind v4
→ Vite production CSS minify (Astro sets cssMinify: true)
→ gab-astro-compress (post-build, CSSO on every .css file)
→ dist/Dev never runs the compress integration. That alone explains dev OK / prod broken if the second pass is the culprit.
Astro forces client CSS minification even when Vite’s SSR default would leave CSS alone:
// astro vite-build-config (simplified)
cssMinify: viteConfig.build?.minify == null ? true : !!viteConfig.build?.minifySo CSS is already minified before any third-party compress step.
Root cause: CSSO 5 + Tailwind v4 media syntax
Tailwind v4 emits range media queries:
@media (width >= 40rem) {
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}Classic (v3-style) form:
@media (min-width: 40rem) { … }gab-astro-compress minifies CSS with CSSO 5.0.5 (css-tree 2.2.1). CSSO drops range queries and keeps legacy ones:
// empirical
csso.minify(`
@media (width >= 40rem) { .sm\\:grid-cols-3 { color: red } }
@media (min-width: 40rem) { .legacy { color: blue } }
.base { color: green }
`).css
// → "@media (min-width:40rem){.legacy{color:#00f}}.base{color:green}"
// range block gone; all sm: utilities gone with itCSSO options do not help. From the CSSO docs: restructure, forceMediaMerge, comments, usage — nothing about modern media features. We tried:
| Options | Range media preserved? |
|---|---|
| default | no |
restructure: false | no |
forceMediaMerge: false | no |
| both false | no |
This is a parse/AST limitation, not aggressive restructuring.
Not a Vite/Astro regression
| Stage | sm: count / @media (width>=…) |
|---|---|
| Vite output only (compress disabled) | present (~21 / yes) |
| After CSSO | zero / no |
Vite’s first minify is fine. The second pass is not.
Why it “worked for months”
The compress package barely moved. The CSS dialect did:
| When | Change |
|---|---|
| Apr 2025 | Tailwind v3 → v4 (@tailwindcss/vite) → range media |
| Jan 2026 | @playform/compress → gab-astro-compress (CSSO) |
| Jan–Feb 2026 | Media grids with many sm: / lg: classes |
| Later | Denser grid (up to 6 columns) → “only two columns” impossible to miss |
Under Tailwind v3 (min-width: …), CSSO was harmless. With v4, every responsive utility is a landmine once CSSO runs. The integration did not need a regression — the input became incompatible.
Dead ends and red herrings
“Just set
css: false” in compress config
Stockgab-astro-compressalways does:const minifyed = csso.minify(cssContent, compressionConfig.css)css: falseis passed into CSSO as options, not treated as “skip this format”. No earlycontinuefor CSS files.“Double minify alone is the bug”
Double minify is real (Vite then CSSO). Damage comes from CSSO’s incompatibility, not from minifying twice per se.CSSO config tweaks
See table above — no option preserves range media.Patch only
dist/index.js
Package exports:"exports": { "import": "./dist/index.mjs", "require": "./dist/index.js" }Astro loads the integration via ESM →
index.mjs. Patching onlyindex.jschanges nothing in the real build. Hours of “the skip is in the file but CSS is still processed” until that showed up.
Fix
1. Keep compress for what still works
Images, HTML, JS, SVG — still valuable on a poster-heavy site.
2. Stop CSSO from touching CSS
Config (documents intent; stock alone is not enough):
// astro.config.mjs
import astroCompress from 'gab-astro-compress'
export default defineConfig({
integrations: [
// …
astroCompress({
css: false,
html: {
minifyCSS: false, // no second pass on inline CSS either
},
}),
],
})3. pnpm patch both entry points
Patch dist/index.mjs and dist/index.js so CSS files are skipped before process and before compress-cache restore (old cache entries can re-apply broken CSSO output).
Conceptually:
// in astro:build:done, for each candidate path:
if (/\.(css|scss|sass|less)$/i.test(candidate)) {
skippedFiles++
continue // do not cache-hit, do not CSSO
}
// in processFile CSS branch (defense in depth):
processingResult = { processed: false, reason: 'Skipped' }Declared in pnpm-workspace.yaml:
patchedDependencies:
[email protected]: patches/[email protected]Create with:
pnpm patch [email protected]
# edit BOTH dist/index.mjs and dist/index.js in the printed directory
pnpm patch-commit '<that-directory>'4. Clear compress cache after the fix
rm -rf node_modules/.astro/.gab-astro-compress
pnpm buildVerify production CSS:
# should be non-zero
rg -c 'sm\\:grid-cols|width>=' dist/_astro/*.css5. Cloudflare: lockfile must match patch config
First deploy after the patch failed with:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
The current "patchedDependencies" configuration doesn't match the value found in the lockfileWorkspace had a path; lockfile only had a hash. Frozen install on CI rejects that.
Working lockfile shape (pnpm lockfile v9):
patchedDependencies:
[email protected]:
hash: <sha256 of the patch file>
path: patches/[email protected]Also pin the client used locally/CI:
"packageManager": "[email protected]"Then:
pnpm install --frozen-lockfile # must succeed locally
git add pnpm-lock.yaml package.json patches/ pnpm-workspace.yamlNote: on pnpm 11, package.json → pnpm.patchedDependencies is ignored (settings live in pnpm-workspace.yaml). Don’t rely on the package.json field alone on modern pnpm.
Workarounds (menu)
| Approach | Pros | Cons |
|---|---|---|
| Skip CSS in compress (what we shipped) | Keep image/HTML/JS/SVG gains; Vite already minifies CSS | Needs a maintained patch until upstream honors css: false |
| Remove compress entirely | Simplest | Lose image recompress / HTML minify |
| Replace CSSO with Lightning CSS / esbuild in a fork | Could minify CSS safely | Maintenance |
Switch to @playform/compress with CSS: false | First-class disable | Different package; re-validate image settings |
| Wait for CSSO/css-tree range-media support | No patch | CSSO 5.0.5 last release Aug 2022; not a plan |
Write all breakpoints as min-width only | Would survive CSSO | Tailwind v4 doesn’t; fighting the framework |
Recommended: skip CSS in the post-build compressor, keep Vite’s minify, patch both ESM and CJS entry files, lockfile path+hash, pin packageManager.
Debugging checklist (if it happens again)
- Compare computed
grid-template-columnsin dev vs preview. - Confirm class names exist in HTML.
- Search built CSS for
sm\:/width>=— missing means purge or minify drop. - Build without compress; if CSS is healthy, the post-build step is guilty.
- Run the minifier alone on a tiny range-media sample.
- Check package
"exports"."import"vs"require"before patching. - After any patch:
pnpm install --frozen-lockfilebefore push.
Takeaways
- Dev ≠ prod often means a build-only plugin, not “Tailwind is broken”.
- Vite already minifies CSS in Astro production builds; a second CSS minifier is optional risk.
- Tailwind v4 range media (
width >=) is not optional decoration — CSSO 5 silently deletes it and every dependent utility. css: falseis not a skip in this compress fork until you patch it (and patchindex.mjs, not onlyindex.js).- pnpm patches need CI-compatible lockfile metadata (
hash+path) or Cloudflare’s frozen install will fail even when the site fix is correct.
Stack at time of writing: Astro 7, Vite 8, Tailwind 4.3, gab-astro-compress 0.1.2, pnpm 11, Cloudflare Pages.