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

EnvironmentLayout
pnpm devResponsive grid (2 → 3 → 4 → 5 → 6 columns)
pnpm build + pnpm previewAlways 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?.minify

So 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 it

CSSO options do not help. From the CSSO docs: restructure, forceMediaMerge, comments, usage — nothing about modern media features. We tried:

OptionsRange media preserved?
defaultno
restructure: falseno
forceMediaMerge: falseno
both falseno

This is a parse/AST limitation, not aggressive restructuring.

Not a Vite/Astro regression

Stagesm: count / @media (width>=…)
Vite output only (compress disabled)present (~21 / yes)
After CSSOzero / 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:

WhenChange
Apr 2025Tailwind v3 → v4 (@tailwindcss/vite) → range media
Jan 2026@playform/compressgab-astro-compress (CSSO)
Jan–Feb 2026Media grids with many sm: / lg: classes
LaterDenser 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

  1. “Just set css: false in compress config
    Stock gab-astro-compress always does:

    const minifyed = csso.minify(cssContent, compressionConfig.css)

    css: false is passed into CSSO as options, not treated as “skip this format”. No early continue for CSS files.

  2. “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.

  3. CSSO config tweaks
    See table above — no option preserves range media.

  4. 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 only index.js changes 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 build

Verify production CSS:

# should be non-zero
rg -c 'sm\\:grid-cols|width>=' dist/_astro/*.css

5. 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 lockfile

Workspace 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.yaml

Note: on pnpm 11, package.jsonpnpm.patchedDependencies is ignored (settings live in pnpm-workspace.yaml). Don’t rely on the package.json field alone on modern pnpm.

Workarounds (menu)

ApproachProsCons
Skip CSS in compress (what we shipped)Keep image/HTML/JS/SVG gains; Vite already minifies CSSNeeds a maintained patch until upstream honors css: false
Remove compress entirelySimplestLose image recompress / HTML minify
Replace CSSO with Lightning CSS / esbuild in a forkCould minify CSS safelyMaintenance
Switch to @playform/compress with CSS: falseFirst-class disableDifferent package; re-validate image settings
Wait for CSSO/css-tree range-media supportNo patchCSSO 5.0.5 last release Aug 2022; not a plan
Write all breakpoints as min-width onlyWould survive CSSOTailwind 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)

  1. Compare computed grid-template-columns in dev vs preview.
  2. Confirm class names exist in HTML.
  3. Search built CSS for sm\: / width>= — missing means purge or minify drop.
  4. Build without compress; if CSS is healthy, the post-build step is guilty.
  5. Run the minifier alone on a tiny range-media sample.
  6. Check package "exports"."import" vs "require" before patching.
  7. After any patch: pnpm install --frozen-lockfile before push.

Takeaways


Stack at time of writing: Astro 7, Vite 8, Tailwind 4.3, gab-astro-compress 0.1.2, pnpm 11, Cloudflare Pages.