Skip to content

CSS shared too widely between pages using client:only islands that ended up on same JS chunk #16453

Description

@ollisal

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

  • I am willing to submit a pull request for this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    - P4: importantViolate documented behavior or significantly impacts performance (priority)pkg: astroRelated to the core `astro` package (scope)triage: fix pendingReporter needs to verify the triage bot fix works

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions