Astro Info
Astro v6.1.9
Vite v7.3.2
Node v24.11.1
System macOS (arm64)
Package Manager npm
Output server
Adapter @astrojs/node (v10.0.6)
Integrations @astrojs/react (v5.0.4)
If this issue only occurs in one browser, which browser is a problem?
No response
Describe the Bug
CSS imported inside a page-specific client:only island leaks to every page in the SSR
production build when Rollup places the CSS-importing JS module in the same output chunk as
an unrelated module that is shared with other client:only islands.
This causes different CSS to be loaded on some pages in the production build than with the devserver, which has no combined JS chunks as an implementation detail.
How it happens
During the client build, plugin-css.ts iterates Object.keys(chunk.modules) for every
chunk that has viteMetadata.importedCss, and calls getParentClientOnlys for EACH
module — walking the source-level module graph upward via getParentModuleInfos (which
has no stopping condition). This associates the chunk's CSS with every reachable
client:only entry, regardless of whether the reached entry actually imports the CSS.
The algorithm assumes that chunk co-tenancy implies CSS ownership. But Rollup's chunking
heuristics optimize for code-splitting and deduplication, not CSS scoping. When unrelated
modules land in the same chunk as CSS-importing JS modules (which happens naturally in large
apps, and can be demonstrated with manualChunks), the CSS leaks to all pages reached
through any module in the chunk.
Impact in our production app
In our production application (~1M LoC), Rollup's default heuristics (no
manualChunks, no splitVendorChunk, default hoistTransitiveImports: true) create a
1,263-module "mega-chunk". This chunk carries 121 KB of page-specific CSS that leaks to
77 routes — including entirely unrelated pages. The leaked CSS causes cascade corruption
because it loads with depth: -1, order: -1 and thus ends up overriding some page-specific CSS that should take priority.
Note, why we end up with such a mega-chunk is a problem of its own, which I'm looking into separately - but it would seem that it doesn't need to affect the CSS<->page association too in this way.
Minimal reproduction structure
The repro uses manualChunks to simulate Rollup's natural chunk merging in a small
project. Without manualChunks, Rollup cleanly separates the modules and no leak occurs
— but in real-world apps with many shared dependencies, the merging happens by default. Being based on heuristics, Rollup chunking is unpredictable, so it would be beneficial to not have it so directly affect CSS scoping too
src/
components/
StyledPanel.tsx # imports StyledPanel.css — only used by HeavyWidget
HeavyWidget.tsx # client:only on home page, imports StyledPanel + formatLabel
CurrentTime.tsx # client:only on every page via layout, imports formatLabel ONLY
formatLabel.ts # pure string utility — NO CSS imports
StyledPanel.css # 16 KB CSS file
layouts/
Layout.astro # <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
CurrentTime does NOT import any CSS. It only uses formatLabel, a pure string utility.
But manualChunks places formatLabel.ts and StyledPanel.tsx in the same chunk. The
walk from formatLabel.ts reaches CurrentTime (via importers), so
StyledPanel.css is associated with every page.
Root cause location
plugin-css.ts lines 89-96: iterates all modules in chunk, not just CSS-importing ones
graph.ts getParentModuleInfos: walks upward with no until boundary
plugin-css.ts getParentClientOnlys: passes no stopping condition
Steps to reproduce
git clone git@github.com:ollisal/astro-client-only-css-leak-repro.git
cd astro-client-only-css-leak-repro
npm install
npm run build
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)
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
What's the expected result?
The StyledPanel css only loaded on the / page.
This actually happens, if you use the exact same example, but change both client:only directives to client:load. In our actual application, we can't unfortunately SSR all React islands however, due to them being partially much older codebase than the current Astro entrypoints. Such a difference between client:only and client:load is not documented either and I hope it's not unavoidable.
Link to Minimal Reproducible Example
https://github.com/ollisal/astro-client-only-css-leak-repro
Participation
Astro Info
If this issue only occurs in one browser, which browser is a problem?
No response
Describe the Bug
CSS imported inside a page-specific
client:onlyisland leaks to every page in the SSRproduction build when Rollup places the CSS-importing JS module in the same output chunk as
an unrelated module that is shared with other
client:onlyislands.This causes different CSS to be loaded on some pages in the production build than with the devserver, which has no combined JS chunks as an implementation detail.
How it happens
During the client build,
plugin-css.tsiteratesObject.keys(chunk.modules)for everychunk that has
viteMetadata.importedCss, and callsgetParentClientOnlysfor EACHmodule — walking the source-level module graph upward via
getParentModuleInfos(whichhas no stopping condition). This associates the chunk's CSS with every reachable
client:onlyentry, regardless of whether the reached entry actually imports the CSS.The algorithm assumes that chunk co-tenancy implies CSS ownership. But Rollup's chunking
heuristics optimize for code-splitting and deduplication, not CSS scoping. When unrelated
modules land in the same chunk as CSS-importing JS modules (which happens naturally in large
apps, and can be demonstrated with
manualChunks), the CSS leaks to all pages reachedthrough any module in the chunk.
Impact in our production app
In our production application (~1M LoC), Rollup's default heuristics (no
manualChunks, nosplitVendorChunk, defaulthoistTransitiveImports: true) create a1,263-module "mega-chunk". This chunk carries 121 KB of page-specific CSS that leaks to
77 routes — including entirely unrelated pages. The leaked CSS causes cascade corruption
because it loads with
depth: -1, order: -1and thus ends up overriding some page-specific CSS that should take priority.Note, why we end up with such a mega-chunk is a problem of its own, which I'm looking into separately - but it would seem that it doesn't need to affect the CSS<->page association too in this way.
Minimal reproduction structure
The repro uses
manualChunksto simulate Rollup's natural chunk merging in a smallproject. Without
manualChunks, Rollup cleanly separates the modules and no leak occurs— but in real-world apps with many shared dependencies, the merging happens by default. Being based on heuristics, Rollup chunking is unpredictable, so it would be beneficial to not have it so directly affect CSS scoping too
CurrentTimedoes NOT import any CSS. It only usesformatLabel, a pure string utility.But
manualChunksplacesformatLabel.tsandStyledPanel.tsxin the same chunk. Thewalk from
formatLabel.tsreachesCurrentTime(viaimporters), soStyledPanel.cssis associated with every page.Root cause location
plugin-css.tslines 89-96: iterates all modules in chunk, not just CSS-importing onesgraph.tsgetParentModuleInfos: walks upward with nountilboundaryplugin-css.tsgetParentClientOnlys: passes no stopping conditionSteps to reproduce
git clone git@github.com:ollisal/astro-client-only-css-leak-repro.git cd astro-client-only-css-leak-repro npm install npm run buildOption A — browser devtools
Open 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)Option B — inspect the SSR manifest directly
Output:
What's the expected result?
The StyledPanel css only loaded on the / page.
This actually happens, if you use the exact same example, but change both
client:onlydirectives toclient:load. In our actual application, we can't unfortunately SSR all React islands however, due to them being partially much older codebase than the current Astro entrypoints. Such a difference betweenclient:onlyandclient:loadis not documented either and I hope it's not unavoidable.Link to Minimal Reproducible Example
https://github.com/ollisal/astro-client-only-css-leak-repro
Participation