Skip to content

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

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

spiceflow React framework. RSC stack based on @vitejs/plugin-rsc#39
remorses wants to merge 222 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

remorses added 30 commits March 16, 2026 12:25
…onable messages

When client-only React APIs (useState, useEffect, createContext, class components, etc.)
are accidentally used in Server Components, React's react-server build resolves them as
undefined, causing a generic TypeError like 'useState is not a function'.

This adds a runtime error formatter (inspired by Next.js's format-server-error.ts) that
pattern-matches these TypeError messages and rewrites them in-place to tell the developer
exactly what went wrong and to add the 'use client' directive.

Hooks covered: useState, useEffect, useReducer, useRef, useLayoutEffect,
useInsertionEffect, useImperativeHandle, useDeferredValue, useActionState,
useSyncExternalStore, useTransition, useOptimistic, useEffectEvent.

Also catches createContext and Class Component errors with specific messages.

Wired into the RSC onError callback in spiceflow.tsx. The SSR onError in entry.ssr.tsx
will be wired in the next commit alongside the SSR flight stream refactor.
…onError

Move the Flight deserialization used for SSR rendering back inside the React DOM
SSR render context so React can register preinit/preload hints for client references.
Previously a single createFromReadableStream was shared for both formState extraction
and SSR rendering, but the eager await happened outside the render context, preventing
React from injecting stylesheet/preload link tags.

Now uses a three-way tee:
- flightForFormState: decoded eagerly for formState
- flightForSsr: decoded lazily inside SsrRoot (within render context)
- flightStream2: injected raw into HTML for client hydration

Also wires formatServerError(e) into the SSR onError callback so client-only API
errors (useState, useEffect, etc.) show actionable messages in the SSR error output.
Move the SSR Flight deserialization used for document rendering back into the React DOM render context, while keeping eager formState extraction on a separate stream branch. This restores client-reference resource hint emission during SSR and adds integration coverage that exercises the document HTML path in both dev and preview builds.
…ority

Address review findings from oracle:

- Abort guard: client disconnects during the 50ms allReady race now
  rethrow instead of being converted to 500 error shells with noisy logs.
  Checks both AbortError name and request.signal.aborted.

- Timer cleanup: the 50ms setTimeout is now cleared when allReady
  resolves/rejects first, avoiding unnecessary timer churn under load.

- Error priority: when multiple digest errors fire from parallel Suspense
  boundaries, redirect now takes priority over notFound via
  shouldReplaceCtx(), preventing a 404 from masking a 3xx redirect.
…eContext matcher

Add integration test that verifies useState in a server component produces
the friendly 'useState only works in Client Components' error message instead
of the raw 'useState is not a function' TypeError. The test fetches /usestate-in-rsc,
asserts 500 status, and checks the inlined flight data contains the rewritten message.

Also applies oracle review fixes:
- createContext matcher now uses regex (\bcreateContext\b.*is not a function)
  so it catches transpiled variants like '(0 , react.createContext) is not a function'
- Added experimental_useOptimistic to the client-only hooks list
- Added test for transpiled createContext error shape
Improve the document render hot path by skipping the extra SSR-side Flight decode for GET/HEAD requests, caching bootstrap script content in production, and trimming the default page payload down to just the fields needed for normal navigations.

Also move injectRSCPayload onto cheaper Uint8Array fast paths for trailer stripping and head injection, plus add regression coverage for the transformed HTML shape. In the nodejs-example benchmarks this lifts Spiceflow /about throughput on both Node and Bun while keeping redirect handling and client-reference preload behavior intact.
…output + update snapshot

The getLocalUrl() helper in test-server.ts was matching raw server output
against a regex expecting 'Local: http://...', but Vite embeds ANSI escape
codes in its colored terminal output (e.g. \x1b[1mLocal\x1b[22m:). The
regex never matched, causing dev and preview server tests to time out after
60s waiting for a URL that was already printed.

Fix: strip ANSI escape codes with .replace(/\x1b\[[0-9;]*m/g, '') before
regex matching.

The hasSiblingSsrEntry snapshot was also outdated — the vite plugin now
puts Cloudflare SSR output exclusively inside dist/rsc/ssr/ (so workerd
can bundle it), meaning dist/ssr/index.js no longer exists. Updated the
inline snapshot to expect false.
Rename the production Playwright command from `test-e2e-preview` to `test-e2e-start` so the test surface matches the actual `start` server being exercised. Tighten default Playwright action and navigation timeouts to 5 seconds so broken hydration and routing regressions fail much faster during local debugging.

Also add regression coverage for progressive-enhancement POST responses keeping client-reference hints, make the server HMR test resilient to shared server state instead of assuming a zero baseline, and add coverage for valid injected Flight script wrappers in the HTML transform tests.
Document throw redirect() and throw notFound() for page and layout handlers,
including custom status codes, headers, correct HTTP semantics vs Next.js,
and client-side navigation behavior.
Move the script tags outside escapeScript() so the entire flight data
payload (prefix + chunk + suffix) is escaped before being wrapped in
<script> tags. Previously only the chunk was escaped, leaving the
prefix/suffix unescaped which could break if flight data contained
sequences matching the literal prefix string.
- Streaming generator: sleep(1500) x2 → sleep(50) x2
- /slow and /slow-suspense pages: sleep(1000) → sleep(100)
- /not-found-in-suspense: sleep(100) → sleep(10)
- Redirects component: sleep(100) → sleep(10)
- Remove racy 'done marker must NOT be visible' assertion from streaming
  test since 50ms delays complete too fast to reliably check

Streaming test dropped from 4.1s to 186ms. Full dev suite from 20s to 16s.
On CI, the Cloudflare Vite dev server prints its Local URL, then triggers
dependency optimization which causes a full program reload. During the
reload, the server accepts TCP connections but never responds — fetch()
hangs indefinitely, bypassing waitForReady's 60s loop timeout (the while
condition is only checked between iterations, not during a hung fetch).

Adding AbortSignal.timeout(5000) to each fetch attempt ensures the call
aborts after 5s, letting the retry loop continue and eventually succeed
once the server finishes reloading.
On CI, the Cloudflare Vite dev server goes through multiple dependency
optimization rounds (copy-anything, superjson, zod, then history, then
isbot) each triggering a full program reload. The server isn't truly
ready until all optimizations complete, which can take well over 60s
on slow CI runners. Bumping to 90s gives enough headroom while staying
under the 120s vitest test timeout.
On CI (fresh dep cache), Vite's Cloudflare dev server goes through
multiple dependency optimization rounds (rsc: copy-anything/superjson/zod,
then rsc: history, then ssr: isbot, then ssr: react-dom/server). Each
round triggers a full program reload. Fetching during these reloads
causes 'Invalid hook call' errors because SSR loads a different React
copy from the optimized deps than the RSC environment — the hooks
resolver resolves to null and every request crashes.

The server never recovers from this state within the test timeout.

Fix: track server output length and wait for it to stabilize (no new
output for 3s) before starting to send fetch requests. This ensures
all dep optimization rounds complete and the server is in a consistent
state before the first request hits it.
On CI (fresh dep cache), Vite discovers deps at runtime and optimizes
them in multiple rounds: rsc (copy-anything, superjson, zod, history)
then ssr (isbot, react-dom/server, history). Each round triggers a full
program reload. After the react-dom/server optimization, SSR loads a
different React copy from the optimized deps than the RSC environment,
causing permanent 'Invalid hook call' errors from RemoveDuplicateServerCss.

Fix: add optimizeDeps.include in the Vite config for both rsc and ssr
environments, listing all deps that Vite discovers at runtime. This
pre-bundles them at startup so no runtime optimization rounds occur
and no program reloads happen.

Also simplify waitForReady: remove the output-stabilization logic (no
longer needed) but keep the 5s fetch timeout and 90s overall timeout
for resilience on slow CI runners.
…dark mode

Strip all padding, border, box-shadow, and horizontal scrollbar from Code Hike
code blocks so code text sits flush with surrounding prose.

Override Code Hike's internal 16px gutter offset (margin-left on SSR,
translate(16px) on client) so code lines align with paragraph and heading text.

Add system-preference dark mode (prefers-color-scheme: dark):
- Page background/text switches to github-dark palette
- Tailwind dark:prose-invert handles typography inversion
- Code Hike --ch-t-* CSS variables overridden to github-dark values
- All 9 github-light inline token colors mapped to github-dark equivalents
  via rgb() attribute selectors (Code Hike serializes style attrs as rgb(),
  not hex, so hex-based selectors silently fail)
- Inline code gets dark background (#161b22)
Cloudflare Workers cancels requests when it detects cross-request promise
resolution during rapid HMR events (save + format save). This is a known
upstream issue (cloudflare/workers-sdk#12731, #9518) where the workerd
runtime kills promises that resolve in a different request context than
the one they were created in.

Four mitigations applied:

1. Add `no_handle_cross_request_promise_resolution` compat flag to
   wrangler.jsonc — tells workerd not to cancel cross-context promises.

2. Track an AbortController per in-flight request in entry.rsc.tsx.
   When a new request arrives or HMR fires, the previous controller is
   aborted and the signal is threaded through the Request object so
   downstream code can detect stale renders.

3. Guard the cross-environment `loadModule` call in handle-ssr.rsc.ts
   with an early abort check. If the request signal is already aborted
   (by HMR), return 503 immediately instead of creating orphaned
   promises via the RSC→SSR bridge.

4. Debounce `rsc:update` HMR events in the browser entry by 80ms.
   When editors save twice rapidly, this collapses them into a single
   RSC refresh, reducing the frequency of the race condition.
The previous transform override (translate(0px, 0px) !important) matched
every line div because they all contain 'translate(16px' in their style.
This zeroed out both X and Y, stacking all lines at top=0.

Instead, shift the parent .ch-code-scroll-content with margin-left: -16px
to counteract Code Hike's 16px X offset while preserving each line's
unique translateY positioning.
…rower page

- Add Source Serif 4 font for headings (weight 400, no bold)
- Increase heading sizes: h1=3em, h2=2.4em, h3=1.8em
- Reduce code block vertical margins from 1.25em to 0.4em
- Tighten spacing between headings and code blocks
- Reduce max page width from 900px to 780px
- Reduce heading line-height to 1.15, tighten heading/paragraph/list margins
- Halve page top padding (pt-3/md:pt-6)
- Update tagline and features across all READMEs to emphasize RSC framework
  and multi-runtime support (Node, Bun, Cloudflare)
… streaming

The handleStream method used `for await...of` inside ReadableStream.start(),
which eagerly drains the async generator regardless of consumer speed — no
backpressure, potential OOM when proxying large SSE streams.

The fix:
- start() is now synchronous: sets up abort handler, ping interval, enqueues init value
- pull() calls iterator.next() one value at a time, only when consumer is ready
- cancel() properly cleans up via shared idempotent cleanup() closure
- cleanup() clears ping interval, removes abort listener, terminates iterator
- error enqueue is guarded so cleanup + close always run even if stream is closed

Added regression test asserting generator is not drained ahead of consumer.

Ref: elysiajs/elysia#1803
…s from canceling each other

The old code used a single AbortController for all in-flight requests.
When concurrent requests arrived for the same page (RSC flight + HTML),
the second would abort the first, causing spurious abort errors.

Now each in-flight request is tracked by URL in a Map, so only
same-URL requests cancel their predecessors. RSC and non-RSC requests
have different URLs and no longer interfere.

Also adds try/finally cleanup to prevent stale controller references
from leaking in the map after a request completes.
…rallel workers

Move the 3 HMR integration tests (client hmr, server hmr, CSS HMR) from
basic.test.ts into a new hmr.test.ts file for better organization.

Disable file parallelism (workers: 1, fullyParallel: false) in playwright
config because HMR tests edit source files on disk, which can interfere
with other tests running concurrently against the same dev server. This
was causing flaky hydration timeouts and scroll restoration failures.
Inspired by Hono's node-server implementation, this applies the practical
performance and correctness fixes without the pseudo-Request/Response pattern.

**Backpressure handling**: replaced manual `reader.read()` while-loop with
`pipeline(Readable.fromWeb(body), res)` from `node:stream/promises`.
The old code ignored the return value of `res.write()`, which causes memory
buildup when clients read slower than the server writes. `pipeline()` also
handles premature client disconnects correctly (settles on 'close' without
'error', unlike manual pipe + event listeners which could hang forever).

**set-cookie header fix**: `Object.fromEntries(response.headers.entries())`
collapses duplicate set-cookie headers into a single value. Now uses
`response.headers.getSetCookie()` + `res.setHeader('set-cookie', array)`
to preserve all cookies (same pattern already used in react/utils/fetch.ts).

**rawHeaders for header construction**: new `newHeadersFromIncoming()` reads
`req.rawHeaders` (flat key/value array) instead of casting `req.headers as
HeadersInit`. This preserves original header casing and properly handles
duplicate headers. Skips HTTP/2 pseudo-headers (colon-prefixed).

**Readable.toWeb() for request body**: replaced manual ReadableStream with
`start`/`data`/`end` event callbacks with Node's built-in
`Readable.toWeb(req)`, which handles edge cases better.

**Event listener cleanup**: switched from `on` to `once` for error/close
handlers to avoid listener accumulation. Removed the deprecated 'aborted'
event listener (Node.js docs recommend using 'close' instead).
…g RSC HMR

@tailwindcss/vite's hotUpdate hook sends a bare {type:'full-reload'} to the
client environment when server-only files change, because Tailwind scans them
for CSS class names. This breaks RSC HMR by causing a full page reload instead
of letting rsc:update + router.refresh() handle it gracefully.

Root cause identified using console.trace on hot.send across all Vite
environments — the stack trace pointed directly at @tailwindcss/vite's
hotUpdate hook in the generate:serve plugin.

This is a known bug fixed in tailwindlabs/tailwindcss#19745 (merged Mar 12)
but not yet released (latest is 4.2.1 from Feb 23). The workaround deletes
Tailwind's hotUpdate hook via configResolved, same approach used by other
RSC frameworks hitting this issue.

Also adds:
- 'main entry hmr' e2e test with window sentinel to verify no full reload
- AGENTS.md section on debugging unwanted full page reloads in Vite
- Corrects AGENTS.md: server HMR does NOT preserve client state
- Waku reference note in AGENTS.md for cross-checking Vite RSC patterns
…nment

Point optimizeDeps.entries at the user's app entry file for all three
environments (client, rsc, ssr) so Vite crawls the full import graph
upfront during dev. This prevents late dependency discovery that triggers
re-optimization rounds and page reloads, especially on fresh installs.

Each Vite environment runs its own independent optimizer, so deps
discovered late by the rsc/ssr optimizer still cause reloads even if
the client optimizer finished. To handle this, also add explicit
optimizeDeps.include lists per environment for known CJS/late-discovered
deps that spiceflow transitively imports:

- client: react, react-dom, superjson, history
- rsc: copy-anything, superjson, zod, history
- ssr: isbot, history, react-dom/server

This replaces the manual optimizeDeps config that was previously needed
in user vite configs (e.g. cloudflare-example). Same approach used by
Waku (dai-shi/waku) for their Vite RSC integration.

Also adds Waku as a reference RSC framework in AGENTS.md.
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