Skip to content

fix: strip dead imports for client:only components#1162

Open
kimjune01 wants to merge 4 commits intowithastro:mainfrom
kimjune01:fix/strip-client-only-imports
Open

fix: strip dead imports for client:only components#1162
kimjune01 wants to merge 4 commits intowithastro:mainfrom
kimjune01:fix/strip-client-only-imports

Conversation

@kimjune01
Copy link
Copy Markdown

Changes

Strips dead imports for client:only components from the compiled .astro output. The compiler emits import Foo from './Foo' for client:only components but passes null to $$renderComponent — the import value is dead code in the prerender environment. However, Rollup still resolves the import and traverses the component's dependency tree (e.g. React, CodeMirror), inflating prerender build time for island-heavy sites.

What this PR does

In print-to-js.go, before printing hoisted imports, builds a set of specifiers that appear exclusively in ClientOnlyComponents (not also in HydratedComponents or ServerComponents). For each hoisted import matching that set, skips it if:

  1. The import has exactly one binding (not mixed like import Repl, { getMeta } from './Repl')
  2. The import is not a type-only import
  3. The import is not a bare side-effect import (import './Repl')

The clientOnlyComponents metadata in the compiled output is preserved — only the dead import statement is removed.

Why this is safe

  • The compiler already passes null to $$renderComponent for client:only: the import value is provably unused in the render path
  • Mixed imports are preserved (guard: len(stmt.Imports) == 1)
  • Side-effect imports are preserved (guard: len(stmt.Imports) == 0 → not matched)
  • Type imports are preserved (guard: !stmt.IsType)
  • Dual-use components (same specifier in both ClientOnlyComponents and HydratedComponents) are preserved (specifier removed from the skip set)

Known limitation

If frontmatter code references the imported binding for non-component purposes (e.g. const Alias = Repl), the import would be incorrectly removed. This is an unlikely pattern for client:only components (which by definition have no server-side use), but could be addressed with binding usage analysis if needed.

Measured impact

906-page static site (Astro 6.1.2), 472 pages with 2,055 client:only React islands:

Experiment Pages Prerender (ms) Delta
Baseline 906 4,338
Remove reading pages entirely 433 1,012 -3,326 (-77%)
Trivial reading pages (no React imports) 906 2,203 -2,135 (-49%)
Switch to client:only="react" 906 4,390 +52 (noise)

The trivial-pages experiment (same page count, no React imports) confirms ~2,135ms of prerender time is attributable to dead client:only import graph traversal.

Compiled output before/after

Before:

import Repl from './Repl';
$$renderComponent($$result, 'Repl', null, {"client:only":"react", ...})

After:

$$renderComponent($$result, 'Repl', null, {"client:only":"react", ...})

The clientOnlyComponents metadata array is unchanged.

Testing

All 6 client:only snapshot tests updated and passing. Full internal test suite passes.

Previously opened (and closed) withastro/astro#16634 with a Vite plugin approach, but moved to the compiler as the cleaner fix point.

The compiler emits `import Foo from './Foo'` for client:only components
but passes `null` to $$renderComponent — the import value is dead code
in the prerender environment. However, Rollup still resolves the import
and traverses the component's dependency tree (e.g. React), inflating
prerender build time.

Skip hoisted imports whose specifier appears exclusively in
ClientOnlyComponents (not also in HydratedComponents or
ServerComponents), but only when the import has exactly one binding
and is not a type-only or bare side-effect import. This avoids breaking:
- Mixed imports: `import Repl, { getMeta } from './Repl'`
- Side-effect imports: `import './Repl'`
- Type imports: `import type { Props } from './Repl'`

Measured on a 906-page site with 2,055 client:only React islands:
prerender build drops from ~4.3s to ~2.2s (49% reduction).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 7, 2026

🦋 Changeset detected

Latest commit: 3cff70d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@astrojs/compiler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

kimjune01 added 3 commits May 6, 2026 22:35
The compiler emits `import Foo from './Foo'` for client:only components
but passes `null` to $$renderComponent — the import value is dead code
in the prerender environment. However, Rollup still resolves the import
and traverses the component's dependency tree (e.g. React), inflating
prerender build time.

Strip hoisted imports when every binding they introduce is an
exclusively client:only component identifier that does not appear in
the frontmatter body. This is binding-aware, not specifier-based:

- Mixed imports (`import Comp, { helper } from './X'`) are preserved
  because `helper` is not a client:only binding
- Separate imports from the same specifier are evaluated independently
- Frontmatter usage of the binding (`console.log(Comp.name)`) prevents
  stripping via a body text scan
- Type-only and bare side-effect imports are never stripped

Measured on a 906-page site with 2,055 client:only React islands:
prerender build drops from ~4.3s to ~2.2s (49% reduction).
- Also exclude ServerComponents from clientOnlyBindings to prevent
  stripping imports used by plain server-rendered components
  (e.g. <Carousel /> without any client: directive)
- Use word-boundary regex instead of bytes.Contains to prevent
  false positives from substring matches (e.g. "X" inside "MAX_WIDTH")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant