Skip to content

ollisal/astro-client-only-css-leak-repro

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Bug Repro: client:only CSS leaks to unrelated pages via shared output chunks

Reproduces on Astro 6.1.9 / Vite 7.3.2 in SSR (output: 'server') mode. Also confirmed on Astro 5.18.1 / Vite 6.4.2.

The bug

In a production build, CSS imported by a page-specific client:only island leaks to every page that has any client:only island — even when those islands have zero CSS dependencies. The dev server is unaffected.

How it works

Astro's getParentClientOnlys (in plugin-css.ts) iterates over every module in an output chunk that has viteMetadata.importedCss, walks the Rollup source-level module graph upward (importers), and associates the chunk's CSS with all reachable client:only entry points. The algorithm treats all modules in a chunk as equally "owning" the CSS, when in reality only specific modules import it.

This means: if Rollup places a CSS-importing module and an unrelated utility module in the same output chunk (which it does by default in large apps), the walk from the utility module escapes to client:only entries that have no relationship to the CSS.

Structure

src/
  components/
    StyledPanel.tsx    # imports StyledPanel.css — only used by HeavyWidget
    HeavyWidget.tsx    # client:only on home page only, uses StyledPanel + formatLabel
    CurrentTime.tsx    # client:only on every page via layout, uses formatLabel ONLY
    formatLabel.ts     # pure string utility — NO CSS imports
    StyledPanel.css    # 16 KB stylesheet — should only appear on /
  layouts/
    Layout.astro       # puts <CurrentTime client:only="react" /> on every page
  pages/
    index.astro        # <HeavyWidget client:only="react" />
    about.astro        # no HeavyWidget — should have NO CSS
    contact.astro      # no HeavyWidget — should have NO CSS

Key: CurrentTime does NOT import any CSS. It only uses formatLabel, a pure utility. StyledPanel imports StyledPanel.css and is only used by HeavyWidget.

Why manualChunks is used

In a minimal repro, Rollup cleanly separates formatLabel.ts into its own chunk and no leak occurs. But in large applications, Rollup's default heuristics merge shared modules into common chunks. In our production app (Astro 5.18.1, ~1M LoC), Rollup creates a 1,263-module "mega-chunk" that carries 121 KB of CSS — this happens with default Rollup settings (no manualChunks, no splitVendorChunk, default hoistTransitiveImports).

We use manualChunks in astro.config.mjs to simulate this natural chunk merging in a minimal project. The manualChunks config forces StyledPanel.tsx (CSS-importing) and formatLabel.ts (pure utility) into the same output chunk, reproducing the same chunk structure that Rollup produces by default at scale.

Steps to reproduce

npm install
npm run build
npm run preview

Option A — browser devtools

Open each page and check the <link rel="stylesheet"> tags in the <head>:

  • http://localhost:4321/ — has shared-utils.*.css (expected, HeavyWidget uses it)
  • http://localhost:4321/about — has shared-utils.*.css (bug: About has no HeavyWidget)
  • http://localhost:4321/contact — has shared-utils.*.css (bug: Contact has no HeavyWidget)

You can also run this in the browser console on any page:

Array.from(document.querySelectorAll('link[rel=stylesheet]')).map(l => l.href)

Option B — inspect the SSR manifest directly

node -e "
const fs = require('fs');
const chunk = fs.readdirSync('dist/server/chunks').find(f => f.startsWith('server_'));
const src = fs.readFileSync('dist/server/chunks/' + chunk, 'utf8');
const start = src.indexOf('deserializeManifest((');
const jsonStart = start + 'deserializeManifest(('.length;
let depth = 0, i = jsonStart;
for (; i < src.length; i++) {
  if (src[i] === '{') depth++;
  if (src[i] === '}') { depth--; if (depth === 0) break; }
}
const manifest = JSON.parse(src.slice(jsonStart, i + 1));
for (const route of manifest.routes) {
  const r = route.routeData?.route;
  if (r && r.startsWith('/') && r[1] !== '_') {
    const css = (route.styles || []).filter(s => s.src?.endsWith('.css')).map(s => s.src);
    console.log(r, '->', css.length ? css.join(', ') : '(no CSS)');
  }
}
"

Output:

/about   -> _astro/shared-utils.*.css   ← BUG: no HeavyWidget on this page
/contact -> _astro/shared-utils.*.css   ← BUG: no HeavyWidget on this page
/        -> _astro/shared-utils.*.css   ✓ correct

Expected

StyledPanel.css (emitted as shared-utils.*.css) appears only on /.

Actual

StyledPanel.css appears on /, /about, AND /contact.

Root cause location

  • astro/dist/core/build/plugins/plugin-css.js lines 89-96 — iterates Object.keys(chunk.modules) and walks from each
  • astro/dist/core/build/graph.js line 27 — getParentModuleInfos walks importers.concat(dynamicImporters) with no stopping condition
  • astro/dist/core/build/plugins/plugin-css.js line 255 — getParentClientOnlys collects all reachable client:only entries

Suggested fix

Instead of walking upward from every module in a chunk to find client:only entries, walk downward from each client:only entry point to determine which CSS files are reachable in its source-level import graph. Only associate CSS with entries that actually import it (directly or transitively). This makes the algorithm independent of Rollup's chunk boundaries.

Alternatively, when walking upward, only start from the modules that actually import the CSS (not from all modules in the chunk).

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors