spiceflow React framework. RSC stack based on @vitejs/plugin-rsc#39
Draft
spiceflow React framework. RSC stack based on @vitejs/plugin-rsc#39
Conversation
… the react server would be optimized for browser
… the client modules where getting duplicated with raw import, fix vite ?import thing
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
This branch replaces the previous custom React Server Components setup with the official
@vitejs/plugin-rscstack and aligns Spiceflow’s React runtime with Vite’s RSC environments.Main changes
@vitejs/plugin-rscforrsc/ssr/clientenvironments.@vitejs/plugin-rsc/rsc,/ssr,/browser)."use client"injection in the Spiceflow Vite plugin (with framework/entry/server-file exclusions)..rscfetch path +__rscquery) and hydration via embedded flight payload.MetaProvider+ collected head tags + HTML stream transform).content-type, and hardens client action payload update timing.Validation
cd spiceflow && rm -rf dist && pnpm tsc --noCheckcd spiceflow && pnpm test --runcd example-react && rm -rf node_modules/.vite && DEBUG_SPICEFLOW=1 npx playwright test --grep-invert '@dev|@build' --timeout 30000