Skip to content

fix: fail loudly when client code imports server-only entry points#2207

Merged
brenelz merged 5 commits into
mainfrom
fix/server-only-import-guard
Jul 18, 2026
Merged

fix: fail loudly when client code imports server-only entry points#2207
brenelz merged 5 commits into
mainfrom
fix/server-only-import-guard

Conversation

@brenelz

@brenelz brenelz commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #2068

What is the current behavior?

A query() without "use server" that imports a server-only utility (the issue's createSomeClient() using vinxi's HTTP APIs — @solidjs/start/http on v2) pulls the entire import chain into the client bundle. Any top-level server-only code then throws during client module evaluation, which kills hydration before the router attaches its handlers — so an unrelated action() form on the same page silently falls back to a native POST to the SSR'd placeholder action="https://action/…" URL and navigates to a dead page. There is no diagnostic anywhere pointing at the actual mistake.

What is the new behavior?

Per review feedback, this now builds on #2167's server-only/client-only boundary modules instead of a separate plugin: the public @solidjs/start/http and @solidjs/start/middleware entries carry an import "server-only" marker, so pulling them into the client bundle fails at resolve time.

  • Dev: error overlay + terminal error on request, instead of a broken page.
  • Prod: hard build failure, instead of shipping a bundle whose forms navigate to https://action/….
  • Legitimate usage unaffected: "use server" functions importing these helpers still work — the directives compiler strips those imports from client modules before resolution ever happens. API routes and middleware resolve in the SSR environment and are untouched. Full playwright e2e suite in apps/tests passes (27 passed, 1 skipped).

Implementation notes

  • The first two commits are feat: support server-only and client-only modules (#2162) #2167 cherry-picked verbatim (credit @YanAnghelp) — this PR should rebase cleanly whenever that lands. On top of it:
    • The boundary-modules plugin is extracted to its own module (unit-testable) and its errors extended with fix guidance ("use server" / clientOnly()).
    • src/http is split: the public entry is import "server-only"; export * from "./http.ts". This is needed because the isomorphic <HttpHeader> component (exported from the root @solidjs/start entry) imports appendHeader/setHeader at module scope and only guards usage behind isServer — with the marker on the implementation itself, every client bundle would fail. Internal isomorphic code imports ./http.ts directly; users go through the marked entry.
  • @solidjs/start/config is no longer guarded (it was in the plugin version): it's loaded directly by Node when evaluating vite.config.ts, outside the Vite plugin pipeline, so a bare server-only import there would crash every app with ERR_MODULE_NOT_FOUND.
  • @solidjs/start/server is deliberately not marked: route files legitimately import types from it (e.g. APIHandler).
  • One trade-off vs. the plugin approach: the resolve-time error names the marked entry (…/@solidjs/start/src/http/index.ts) as the offending client module rather than the user's file that imported it — the import chain isn't available in resolveId. The extended message compensates by stating the fix.

🤖 Generated with Claude Code

A query() without "use server" that imports a server-only utility pulls
the whole import chain into the client bundle. Server-only code then
throws during client module evaluation, killing hydration before the
router attaches its handlers - forms silently fall back to a native
POST to the placeholder action URL and navigate to a dead page, with
no diagnostic pointing at the actual mistake.

Add a resolve-time guard that errors in the client environment when a
module imports @solidjs/start/http, /middleware, or /config, naming the
importing file and the fix. Legitimate "use server" usage is unaffected
because the directives compiler strips those imports from client
modules before resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 42328dd

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

This PR includes changesets to release 1 package
Name Type
@solidjs/start 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

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for solid-start-landing-page ready!

Name Link
🔨 Latest commit 42328dd
🔍 Latest deploy log https://app.netlify.com/projects/solid-start-landing-page/deploys/6a5b07b0cfdf4d0008911194
😎 Deploy Preview https://deploy-preview-2207--solid-start-landing-page.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@solidjs/start@2207

commit: 42328dd

YanAnghelp and others added 3 commits July 17, 2026 15:13
…a separate plugin

Per review feedback, drop the standalone server-only-guard plugin and
instead build on the boundary-modules plugin from #2167: the public
@solidjs/start/http and @solidjs/start/middleware entries now carry an
import "server-only" marker, so pulling them into the client bundle
fails at resolve time with an actionable error (#2068).

- extract boundary-modules into its own module and extend its errors
  with fix guidance ("use server" / clientOnly())
- split src/http into a marked public entry re-exporting ./http.ts, so
  the isomorphic <HttpHeader> (which guards usage behind isServer) can
  keep importing the helpers without tripping the client-env check
- @solidjs/start/config is no longer guarded: it is loaded directly by
  Node when evaluating vite.config.ts, outside the plugin pipeline, so
  it cannot carry the marker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brenelz
brenelz requested a review from lxsmnsyc July 17, 2026 20:23
@brenelz
brenelz merged commit a8a2328 into main Jul 18, 2026
10 checks passed
@brenelz
brenelz deleted the fix/server-only-import-guard branch July 18, 2026 15:18
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.

[Bug?]: Server-only imports in unused query silently breaks unrelated action() form

3 participants