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.
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.
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.
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.
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.
npm install
npm run build
npm run previewOpen each page and check the <link rel="stylesheet"> tags in the <head>:
http://localhost:4321/— hasshared-utils.*.css(expected, HeavyWidget uses it)http://localhost:4321/about— hasshared-utils.*.css(bug: About has no HeavyWidget)http://localhost:4321/contact— hasshared-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)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
StyledPanel.css (emitted as shared-utils.*.css) appears only on /.
StyledPanel.css appears on /, /about, AND /contact.
astro/dist/core/build/plugins/plugin-css.jslines 89-96 — iteratesObject.keys(chunk.modules)and walks from eachastro/dist/core/build/graph.jsline 27 —getParentModuleInfoswalksimporters.concat(dynamicImporters)with no stopping conditionastro/dist/core/build/plugins/plugin-css.jsline 255 —getParentClientOnlyscollects all reachableclient:onlyentries
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).