Skip to content

spiceflow React framework. RSC stack based on @vitejs/plugin-rsc#39

Draft
remorses wants to merge 101 commits intomainfrom
rsc-merge-main
Draft

spiceflow React framework. RSC stack based on @vitejs/plugin-rsc#39
remorses wants to merge 101 commits intomainfrom
rsc-merge-main

Conversation

@remorses
Copy link
Owner

@remorses remorses commented Mar 1, 2026

Context
This branch replaces the previous custom React Server Components setup with the official @vitejs/plugin-rsc stack and aligns Spiceflow’s React runtime with Vite’s RSC environments.

Main changes

  • Migrates from the old custom pipeline to @vitejs/plugin-rsc for rsc / ssr / client environments.
  • Switches runtime integration to plugin-rsc APIs in entrypoints (@vitejs/plugin-rsc/rsc, /ssr, /browser).
  • Makes user source client-by-default through auto "use client" injection in the Spiceflow Vite plugin (with framework/entry/server-file exclusions).
  • Keeps RSC navigation model (.rsc fetch path + __rsc query) and hydration via embedded flight payload.
  • Preserves SSR metadata injection flow (MetaProvider + collected head tags + HTML stream transform).
  • Adds migration follow-up cleanup: removes leftover dead symbols/types, fixes SSR redirect content-type, and hardens client action payload update timing.

Validation

  • cd spiceflow && rm -rf dist && pnpm tsc --noCheck
  • cd spiceflow && pnpm test --run
  • cd example-react && rm -rf node_modules/.vite && DEBUG_SPICEFLOW=1 npx playwright test --grep-invert '@dev|@build' --timeout 30000

Merge 228 commits from main (since divergence at ed746c4) into the rsc branch,
bringing the RSC framework up to date with all core improvements.

## Merged from main

- **Standard Schema validation**: replaced Ajv + zod-to-json-schema with
  `@standard-schema/spec` (`~standard.validate`). Dropped `@sinclair/typebox`,
  `ajv`, `ajv-formats`, `lodash.clonedeep` deps.
- **Zod v4**: upgraded from v3 to `^4.3.6`, added `isZod4()` helper.
- **New `.route()` method**: flexible multi-method registration with `method` as
  single value, array, or `'*'` wildcard.
- **`.safePath()` method**: type-safe path builder using new `RoutePaths` type param.
- **`waitUntil` support**: constructor option + `wrappedWaitUntil` in context for
  background task lifecycle management.
- **`disableSuperJsonUnlessRpc`**: opt-in to skip SuperJSON for non-RPC clients,
  with `superjsonSerialize` moved to a class method with RPC detection.
- **MCP refactor**: extracted `openapi-to-mcp.ts`, switched to `McpServer` class,
  added `addMcpTools()`, tool name sanitization, basePath-aware transport URLs.
- **`handle()` as arrow function** with optional `{ state }` second argument for
  dependency injection (Cloudflare-compatible binding).
- **`body` → `request` schema alias**: `InputSchema.request` with `GetRequestSchema`
  helper, client types updated accordingly.
- **`copy-anything`** replaces `lodash.clonedeep`.
- **`handleForNode`/`listenForNode`** extracted to `_node-server` module.
- **Website**: migrated from Remix to React Router v7, wrangler v3→v4.
- **SSE streaming**: always returns SSE format even for single-value generators,
  added `content-encoding: none` and `cache-control: no-cache` headers.
- **Client**: added `retries` option, `url` field in `ClientResponse`.
- **`onError`**: drops `MaybeArray`, receives `path` (with query string).
- **CORS**: fix for Cloudflare due to uppercase header change.
- **Various**: `joinBasePaths()` helper, status code validation (100-599 range),
  error `statusCode` fallback, route override support.

## Preserved from rsc

- **TrieRouter** (multi-match for page + layout routing)
- **React Server Components**: `page()`, `layout()`, `staticPage()`, `renderReact()`
- **Vite plugin**, client/SSR entries, error boundaries, progress bar
- **RSC types**: `NodeKind`, `InternalRoute.id/kind`, `SpiceflowContext.children`
- **RSC example app** and Playwright e2e tests

## Conflict resolution

17 files had conflicts. Strategy: keep rsc's architecture (TrieRouter, React
rendering, JSX) and port main's improvements into it. All `.ts` import
extensions normalized to `.js` (rsc convention for NodeNext module resolution).

## Test results

- **216 tests passing** (was 174 before merge — gained 42 new tests from main)
- **7 tests skipped**: TrieRouter behavioral differences vs MedleyRouter
  (route override last-registered-wins, wildcard priority, catch-all precedence)
- **4 todo** tests (unchanged from before)
Replace the custom RSC setup (@jacob-ebey/react-server-dom-vite + unplugin-rsc +
hand-rolled reference tracking) with the official @vitejs/plugin-rsc@0.5.21.

## What changed

**Dependencies:**
- Remove @jacob-ebey/react-server-dom-vite, unplugin-rsc, @hiogawa/transforms,
  @hiogawa/utils
- Add @vitejs/plugin-rsc@0.5.21, react-server-dom-webpack@19.2.4
- Upgrade react/react-dom to 19.2.4

**vite.tsx — completely rewritten:**
- Replace ~600 lines of custom RSC plugin code (manual client/server reference
  tracking, custom environment configs, SSE-based HMR, manual module graph
  walking) with a thin wrapper around `rsc()` from @vitejs/plugin-rsc
- Add `spiceflow:optimize-deps-rewrite` plugin to rewrite optimizeDeps entries
  with `spiceflow >` prefix so vendor CJS files resolve through the framework
  package (where plugin-rsc is installed) rather than from the app root
- Add `spiceflow:auto-use-client` plugin for client-by-default behavior — auto
  injects `"use client"` into user source files unless they already have a
  directive, are the app entry, node_modules, spiceflow internals, or .server.*

**Entry points updated to use plugin-rsc APIs:**
- entry.client.tsx: import from `@vitejs/plugin-rsc/browser` instead of
  custom references.browser + react-server-dom-vite/client
- entry.ssr.tsx: import `createFromReadableStream` from
  `@vitejs/plugin-rsc/ssr`, use `import.meta.viteRsc.loadModule` and
  `import.meta.viteRsc.loadBootstrapScriptContent`
- entry.rsc.tsx: simplified — just imports app entry and exports handler

**spiceflow.tsx — renderReact updated:**
- Import renderToReadableStream, decodeReply, decodeAction, decodeFormState,
  loadServerAction from `@vitejs/plugin-rsc/rsc` instead of custom wrappers
- Remove manual client reference manifest handling

**Deleted files (old custom RSC infrastructure):**
- references.browser.tsx, references.rsc.tsx, references.ssr.tsx
- utils/client-reference.ts, utils/normalize.ts
- react/types/index.ts

**Example app fixes for stricter RSC semantics:**
- action.tsx: make getCounter async (plugin-rsc rejects non-async exports in
  "use server" modules)
- index.tsx: receive counter + serverRandom as props instead of calling server
  functions during render (client components can't call server functions during
  SSR initial render)
- main.tsx: fetch data in page handlers and pass as props — correct RSC pattern
- e2e tests: fix selectors for dual counter elements, replace `using` keyword
  with try/finally for Playwright compat

## Test results
- 12/12 Playwright e2e tests pass (dev mode)
- 216/216 spiceflow package tests pass
- Production build: 4/5 stages succeed, prerender step has a timing issue with
  __vite_rsc_assets_manifest.js generation (TODO)
Remove dead symbols left after switching to @vitejs/plugin-rsc and tighten two runtime edges so the new stack behaves predictably. The cleanup drops unused framework-only exports and old debug-only vestiges, while preserving the new official plugin flow.

Client action handling now updates payload state before awaiting action results to avoid stale UI timing, and SSR redirect responses now emit a proper content-type header for correct downstream handling. Also add a patch changeset documenting this migration follow-up.
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 1, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
spiceflow-website-worker 101c867 Mar 02 2026, 03:55 PM

remorses added 2 commits March 1, 2026 23:31
The previous code captured history.location once at module init time,
so router.location was always the initial location object and never
reflected subsequent navigations. Changing to a getter ensures every
access returns the live value from the history instance.
@remorses remorses changed the title migrate spiceflow RSC stack to official @vitejs/plugin-rsc spiceflow React framwork. RSC stack based on @vitejs/plugin-rsc Mar 2, 2026
@remorses remorses changed the title spiceflow React framwork. RSC stack based on @vitejs/plugin-rsc spiceflow React framework. RSC stack based on @vitejs/plugin-rsc Mar 2, 2026
remorses added 11 commits March 2, 2026 13:21
Detailed architecture reference for building RSC-based frameworks on top of
@vitejs/plugin-rsc. Covers the three-environment model (RSC/SSR/client),
the 5-step scan+build pipeline and why scan phases are required, complete
annotated entry point implementations, client-side navigation patterns
(listenNavigation + request conventions), server action dispatch (both
post-hydration and progressive enhancement), RSC payload design, CSS
handling, cross-environment module loading APIs, HMR integration, SSR
error recovery with CSR fallback, no-SSR and browser-only modes,
production deployment structure, and known architectural constraints.

Derived from reading all examples (starter, basic, react-router, browser,
no-ssr) and internal docs (architecture.md, bundler-comparison.md, notes/)
in the vite-plugin-react repository.
… tests

## Bugs fixed

- **isRedirectError/isNotFoundError called with wrong type**: these functions
  expect a ReactServerErrorContext (parsed from error digest) but were receiving
  raw Error objects, so redirect and not-found errors were never caught in the
  API error handling path. Now properly wrapped with getErrorContext(err).

- **turnHandlerResultIntoResponse crash when route is undefined**: the React
  middleware path passed undefined for the route parameter, but the method body
  accessed route.type without optional chaining. Added route?.type guard.

- **Unreachable code in prerender.ts**: continue after throw was dead code.

## Duplicated code removed

- checkOptionalParameter existed in both trie-router/url.ts and
  trie-router/utils.ts — removed the unused copy from url.ts.

- Standalone turnHandlerResultIntoResponse + superjsonSerialize functions
  duplicated the class methods but were missing disableSuperJsonUnlessRpc
  support. Removed them; React middleware path now uses the class method.

- ReactServerErrorContext type was defined in both components.tsx and
  errors.tsx — components.tsx now imports from errors.tsx.

- InternalRoute was re-exported from spiceflow.tsx despite being defined in
  types.ts — removed re-export, openapi.ts imports from types.ts directly.

## Routing improvements

- Replaced sort()[0] route selection with pickBestRoute that properly scores
  routes: fewer wildcards > fewer named params > more segments > last
  registered wins for same pattern (override semantics).

- :param now always beats * regardless of registration order.

## Visibility and cleanup

- router and usedIds made private on Spiceflow class.
- Deleted play.js scratch benchmark file.
- Added Hono MIT attribution to trie-router/node.ts.

## Tests

- Re-enabled all 7 previously skipped tests, updated expectations to match
  trie router behavior (/* matches parent path, ALL /* catches all methods).
- Added new test: :param beats wildcard regardless of registration order.
- Strengthened * param test to assert returned value, not just status.
- 224 tests passing, 0 skipped.
Add four new sections derived from reading react-router's RSC implementation
(packages/react-router/lib/rsc/):

- **RSC Payload Design**: expanded from minimal {root, formState} to show the
  discriminated union pattern (render | action | redirect | manifest). The payload
  is where frameworks communicate control flow to the client — redirects, errors,
  route patches all travel through one RSC stream.

- **Server vs Client Component Data Passing**: server components receive data as
  direct props during RSC rendering, client components need hooks/context because
  they execute later during hydration. Shows isClientReference() detection and the
  WithComponentProps wrapper pattern from React Router.

- **CSRF Protection**: Origin header validation on server action POST requests
  with allowedOrigins whitelist pattern.

- **Action + Rerender Streaming**: action result streams immediately while
  revalidation (re-running loaders + rendering) happens in parallel. Uses
  renderToReadableStream's ability to serialize promises for incremental delivery.
…gin-rsc best practices

Compared spiceflow's RSC implementation against the plugin-rsc framework author
guide and identified 7 issues ranging from a security gap (no CSRF protection on
server actions) to missing build config options (defineEncryptionKey,
validateImports).

Plan reviewed and validated by oracle agent which confirmed all items and added
3 additional findings: createFromReadableStream placement, defineEncryptionKey
for production stability, and validateImports for build-time safety.
…emporary references

Align spiceflow with @vitejs/plugin-rsc best practices:

**CSRF protection** (spiceflow.tsx)
- Validate Origin header on POST requests before processing server actions
- Return 403 'origin mismatch' for cross-origin requests
- Support allowedActionOrigins option for trusted origins (string or RegExp)
- Check runs before try/catch so 403 is returned directly, not swallowed

**SSR error fallback** (entry.ssr.tsx + entry.client.tsx)
- Replace inert data-no-hydrate attribute with self.__NO_HYDRATE=1 in bootstrap script
- Browser entry checks '__NO_HYDRATE' in globalThis and uses createRoot instead of
  hydrateRoot, avoiding hydration mismatch errors against the error shell HTML
- Added unit tests for the error shell rendering and flag detection

**Temporary references** (spiceflow.tsx)
- Create temporaryReferences set per request, shared between decodeReply and
  renderToReadableStream so non-serializable values (DOM nodes, React elements)
  round-trip correctly through server action encode/decode

**RSC HMR self-accept** (entry.rsc.tsx)
- Accept own HMR updates so server code changes trigger efficient RSC stream
  re-render instead of full page reload

**validateImports** (vite.tsx)
- Enable validateImports option to catch server-only code leaking into client bundles

**Test page** (example-react)
- Add /ssr-error-fallback route with ThrowsDuringSSR component for e2e testing
…ators

**New e2e tests:**
- SSR error fallback: verify CSR recovery when SSR fails via __NO_HYDRATE
- SSR error fallback: verify __NO_HYDRATE flag is set on globalThis
- SSR error fallback: verify normal pages don't contain __NO_HYDRATE
- CSRF: verify cross-origin POST returns 403
- CSRF: verify same-origin POST passes CSRF check

**HMR test fixes:**
- Fix strict mode violations in client/server HMR tests by using
  .filter({ hasText: 'Client counter' }) to disambiguate the locator
  when multiple client-counter test-id elements exist on the page
- Skip client HMR test: RSC architecture causes SSR page reload on client
  component changes, which races with client-side HMR and prevents
  the edited text from appearing reliably
Add detailed documentation covering:

- How Vite's ModuleRunner caches modules via EvaluatedModules Map
  (promise-based caching, single-line cache check in cachedRequest)
- Invalidation flow: goes UP to importers, not DOWN to dependencies,
  so unchanged route handlers/libs are served from cache instantly
- The RSC plugin's hotUpdate hook: checks client boundary membership,
  sends rsc:update for server-only changes, React Fast Refresh for
  client changes
- Performance analysis for large apps: only edited file + importers
  up to HMR boundary are re-evaluated, no bundling step
- Three patterns for persistent resources (DB pools) across HMR:
  globalThis, import.meta.hot.data, or stable unchanged files
- ASCII flow diagram showing the full server-side HMR pipeline
- Source links to vite/src/module-runner/*.ts for each mechanism
Add documentation for the full-stack React framework features:
- Setup instructions with spiceflowPlugin Vite config
- App entry pattern with .page() and .layout() for server components
- Client components with "use client" directive and useState example
- Server actions with "use server" directive
- Client code splitting explanation: automatic per-file chunking via
  @vitejs/plugin-rsc, how the RSC flight stream references chunks on
  demand, and the barrel file anti-pattern ("use client" on a barrel
  defeats splitting — put directive in each individual component file)

Also add React framework bullet to the Features list.
…gger server re-render

Add a server-side render counter (serverRenderCount) that increments on each RSC
render of the home page, exposed via data-testid="server-render-count".

The client HMR test now asserts that:
- Editing a client component does NOT increment the server render count
- Client state is preserved (counter stays at 1 after edit)
- The edited text appears via React Fast Refresh

This proves client component edits only trigger client-side HMR, not a server
re-render. Vite's SSR environment logs 'page reload' internally but the browser
does not actually reload — React Fast Refresh handles the update.

Also update AGENTS.md e2e testing section with:
- Accurate HMR behavior (client HMR preserves state, no server re-render)
- Warning about replace() no-ops when the search string doesn't exist in source
- Documentation for the serverRenderCount test helper
Add e2e test proving that an async generator passed as a prop from a server
component to a client component streams items incrementally — the client
starts rendering before the generator completes.

The test uses waitUntil:'commit' so Playwright begins observing while the
HTML stream is still open. It asserts:
- 'message-1' is visible while the 'done' marker is NOT (generator has ~3s left)
- All 3 items arrive in order once the generator finishes

This works because React 19's flight protocol natively serializes async
iterables: the server yields values progressively via X/x flight chunks,
and the client reconstructs a live AsyncIterable backed by a promise queue
that resolves as chunks arrive over the wire.

New files:
- example-react/src/app/streaming-consumer.tsx — client component consuming
  AsyncIterable<string> via useEffect + for-await
- /streaming route in main.tsx with 1.5s delays between yields
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