Skip to content

feat(vite-plugin): RSC dev support via viteEnvironment child environments#47

Open
agcty wants to merge 11 commits into
alchemy-run:mainfrom
agcty:feat/rsc-dev
Open

feat(vite-plugin): RSC dev support via viteEnvironment child environments#47
agcty wants to merge 11 commits into
alchemy-run:mainfrom
agcty:feat/rsc-dev

Conversation

@agcty

@agcty agcty commented Jun 14, 2026

Copy link
Copy Markdown

Status / stack

Ready for review and intended to merge first.

What

Adds React Server Components dev support to the Cloudflare vite plugin by generalizing the hardcoded single ssr worker environment into an entry environment + child environments, via the same viteEnvironment: { name, childEnvironments } contract the official @cloudflare/vite-plugin exposes.

@vitejs/plugin-rsc apps (React Router RSC, Waku) run the worker in the rsc environment (resolved with the react-server condition) and load ssr from it at runtime via import.meta.viteRsc.loadModule("ssr", ...). The single-env assumption could not host that, so vite dev failed two ways:

  1. Vite Internal Error: registerMissingImport is not supported in dev rsc at startup - the rsc env never got the worker treatment (noDiscovery: false), so it carried Vite's throwing deps-optimizer stub.
  2. Past that, SSR rendered with a null hooks dispatcher - a duplicate React from a mid-session deps re-optimization in the ssr child (no scan root -> lazy discovery -> re-hash).

Approach

  • viteEnvironment?: { name?, childEnvironments? } (default { name: "ssr" }, no children -> unchanged) + a workerEnvironments(options) helper.
  • The worker treatment (workerd resolve conditions + dep pre-bundling) now applies to the entry env and each child, built per-env via makeWorkerEnvironment(name, { isEntry }) so each carries its own optimizeDeps.entries - a shared entries left the child with no scan root, which is what caused the React duplication.
  • Dev connects a module runner for the entry env and each child in the same isolate (one ModuleRunnerDO), and awaits each env's depsOptimizer.init() before its runner imports, so deps settle in one pass and React stays a singleton. Cross-env loadModule resolves through the existing __VITE_ENVIRONMENT_RUNNER_IMPORT__ path.
  • workerEnvironments rejects invalid configs (name "client", child/entry collisions, duplicate children), matching the official plugin.

The default path (no viteEnvironment) produces equivalent config to before, so non-RSC apps are unaffected.

What works

vite dev renders end to end on two fixtures:

  • fixtures/react-rsc - minimal @vitejs/plugin-rsc starter.
  • fixtures/react-router-rsc - React Router on RSC (routes, server actions, client components), single-worker child-env topology, plus a /worker-render route exercising the loadModule("ssr", "worker-ssr") pattern (worker-side react-dom/server rendering routed through the ssr env rather than imported in the rsc entry).

Server-component HMR is hot (rsc:update).

Tests

bun test per fixture (dev-mode smoke tests that boot vite dev and assert the routes/worker-render respond):

  • react-rsc - server + client component + server action render; plus a plugin-order lock (vite.config.cf-first.ts) proving RSC resolves correctly even when the Cloudflare plugin is registered before rsc() (export-condition resolution is set-membership, so react-server's presence, not its index in the conditions array, is what matters).
  • react-router-rsc - home, /about, and /worker-render.

No regression: the existing static-website suite (vite build + miniflare + playwright) passes; tsc -b is clean across packages.

Scope / follow-ups

  • Dev only. Production build for the RSC topology lands separately in feat(vite-plugin): RSC build manifest for deploy #50 as an explicit deploy manifest.
  • Client-component HMR full-reloads (vs Fast Refresh). plugin-rsc defers client-boundary edits to Vite's default HMR, which in a workerd ssr env becomes a reload; appears inherent to a worker-hosted ssr env rather than specific to this change. Pointers welcome.
  • Single worker. viteEnvironment is threaded at the top level (one worker), with isEntry standing in for the entry/parent role. If you would prefer it structured per-worker later (for auxiliaryWorkers / a prerender worker) with an explicit entry-vs-parent split, that should be a follow-up rather than bundled into the dev fix.

Written on behalf of agcty.

@agcty agcty marked this pull request as ready for review June 15, 2026 16:49
agcty added 11 commits June 15, 2026 23:55
The dev plugin hardcoded a single worker environment named "ssr". RSC
apps (@vitejs/plugin-rsc: React Router RSC, Waku) run the worker in the
"rsc" environment (react-server condition) and load "ssr" from it at
runtime, so a single-env assumption can't host them — dev crashed with
"registerMissingImport is not supported in dev rsc" and, once past that,
failed SSR with a duplicate-React null dispatcher.

Add a viteEnvironment { name, childEnvironments } option (mirroring the
official @cloudflare/vite-plugin) and generalize the single "ssr" env to
an entry env plus its children:

- each worker env gets the workerd resolve conditions + dependency
  pre-bundling (noDiscovery: false), built per-env so each carries its
  own optimizeDeps.entries (a shared entries seeded from the rsc main
  left the ssr child with no scan root, causing a mid-session
  re-optimization that re-hashed and duplicated React)
- dev connects a module runner for the entry env and each child, and
  awaits each env's depsOptimizer.init() before its runner imports
- the worker entry environment name is the configured entry (was "ssr")

Default (no viteEnvironment) is unchanged: entry "ssr", no children.
Minimal @vitejs/plugin-rsc starter (react-rsc) and a React Router on RSC
app (react-router-rsc), both wired through the child-environment model
(viteEnvironment: { name: rsc, childEnvironments: [ssr] }) to exercise
RSC dev.
- restore the exact non-RSC optimizeDeps.entries behavior: the per-env
  input fallback now applies only to multi-environment (RSC) topologies;
  single-worker apps keep entries solely from an explicit `main` (was a
  behavioral change vs the original single-"ssr" code)
- give the actionable maintainer-style hint when a runner is missing for
  an environment (point at viteEnvironment.childEnvironments)

Verified: RR-RSC fixture still renders; non-RSC (static-website) dev
unchanged.
… pattern)

Adds a /worker-render endpoint that loads a custom "worker-ssr" module
from the ssr environment via loadModule("ssr", "worker-ssr") instead of
importing react-dom/server in the rsc worker entry — the blessed pattern
from agcty/vite-rsc-worker-env-repro#1. Proves the distilled plugin
handles: multiple ssr inputs (framework index + worker-ssr), the worker
loading a non-index ssr module cross-environment, and react-dom/server
resolving in the ssr child (it would fail under the rsc react-server
condition). Verified: GET /worker-render → 200 with rendered HTML; / still renders.
Reject viteEnvironment configs that would silently corrupt the generated
per-env config via computed keys: name "client" (the reserved browser
env), children including "client", children colliding with the entry,
and duplicate children. Mirrors @cloudflare/vite-plugin. (review finding)
Boot `vite dev` and assert the RSC routes render: react-rsc (server +
client component + server action) and react-router-rsc (home, /about,
and /worker-render exercising loadModule("ssr","worker-ssr")). Makes RSC
dev support an automated, runnable check (`bun test`). Build-mode is a
separate track and intentionally not covered.
A reviewer flagged that the rsc env's resolve.conditions ordering depends
on plugin order: with cloudflare() before rsc(), react-server lands at
index 5 instead of 0. Verified via resolveConfig probe that the ordering
does change — but export-condition resolution is set-membership (React's
own exports key order decides), so react-server being present is what
matters, not its array index. Adds vite.config.cf-first.ts (cloudflare
before rsc) + a test booting it: the app renders 200 with a working RSC
flight stream, proving order-independence. Not a correctness bug; locked
so it can't silently regress.
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