Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
082fdff
feat: add rsc property to route module exports
teneburu Jun 29, 2026
f06d9b9
refactor: extract filePathToRoutePath utility
teneburu Jun 29, 2026
309ffe9
feat: add @vitejs/plugin-rsc and configure RSC build environment
teneburu Jun 29, 2026
bfe6036
feat: register RSC virtual modules (rsc-entry, ssr-entry, rsc-content)
teneburu Jun 29, 2026
e407fda
feat: add TypeScript RSC virtual module support
teneburu Jun 29, 2026
684f067
feat: add Fastify-to-Request adapter for RSC handler
teneburu Jun 29, 2026
aa9ef00
feat: add RSC route branching and companion _.rsc routes
teneburu Jun 29, 2026
6964bf7
feat: add mount.js RSC hydration path
teneburu Jun 29, 2026
222ab4f
feat: add plugin runtime hooks for RSC module resolution
teneburu Jun 29, 2026
a674f5e
fix: refactor development.ts for environment-based config
teneburu Jun 29, 2026
0bc2f9c
chore: add RSC e2e test fixture with updated virtual modules
teneburu Jun 29, 2026
e8f48df
fix: use TS RSC content import in TypeScript core variant
teneburu Jun 29, 2026
c126b45
test: expand RSC e2e fixture with additional route patterns
teneburu Jun 29, 2026
4cb11d2
fix: resolve RSC companion route 404s, production build, and Router c…
teneburu Jun 30, 2026
bc7e434
fix(react): port RSC hydration path to TypeScript mount module
teneburu Jun 30, 2026
daf0072
fix(react): correct payload access and drop duplicate hydration in TS…
teneburu Jun 30, 2026
585c6f3
fix(react): drop static Youch import and dedupe route util in TS rsc-…
teneburu Jun 30, 2026
289a43b
fix(react): remove dead _R_ script detection from mount.js
teneburu Jun 30, 2026
2800beb
fix(react): include RSC virtual modules in published files array
teneburu Jun 30, 2026
48841fc
test(rsc): fix node --test suite skip API and rename diagnostic script
teneburu Jun 30, 2026
a0b0c5c
test(e2e): add makeRscIndexTest factory for RSC flight data assertions
teneburu Jun 30, 2026
068fca6
fix(react): set keepProcessEnv false on RSC environment
teneburu Jun 30, 2026
b3d0bc5
chore: update pnpm-lock.yaml
teneburu Jun 30, 2026
e07b0ee
test(vite): update development.test mock for environment-based config
teneburu Jun 30, 2026
0f82c30
Add changeset for RSC support
teneburu Jun 30, 2026
84d3721
feat(react): implement onEnter lifecycle and Valtio state bridge for …
teneburu Jun 30, 2026
12f12af
test(rsc): add Valtio state e2e fixture with context seeding and Stat…
teneburu Jun 30, 2026
ad359ab
style: fix test formatting
teneburu Jun 30, 2026
468775b
fix(react): bridge Valtio state into onEnter ctx and merge onEnterDat…
teneburu Jun 30, 2026
524a05d
fix(react): suppress Valtio console.warn by using getVersion guard in…
teneburu Jun 30, 2026
791c9c6
fix(vite): use RunnableDevEnvironment.runner in loadEntries to avoid …
teneburu Jun 30, 2026
097468d
fix(react): prevent stream racing in RSC route handler and strip Cont…
teneburu Jun 30, 2026
b98f5aa
refactor(react): stream RSC HTML progressively instead of buffering
teneburu Jun 30, 2026
60448d2
fix(react): align TS virtual module variants with JS counterparts
teneburu Jul 1, 2026
65fe4a3
refactor(react): remove dead applyHeadFromPayload from rsc-content
teneburu Jul 1, 2026
b15efae
test(react): add rsc + getData mutual-exclusion test
teneburu Jul 1, 2026
d6f029c
fix(react): resolve unused variable lint warnings in RSC entries
teneburu Jul 1, 2026
0b4247f
fix(react): drop unused bindings flagged by no-unused-vars
teneburu Jul 1, 2026
8258072
feat(react): thread Fastify server and req into RSC onEnter context
teneburu Jul 1, 2026
69af69d
Merge branch 'fastify:main' into feat/rsc-support
teneburu Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/rsc-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@fastify/react": minor
"@fastify/vite": patch
---

Add React Server Components (RSC) support to `@fastify/react` via `@vitejs/plugin-rsc`. Routes opt in with `export const rsc = true`; the plugin registers a 3-environment build (client/rsc/ssr), companion `_.rsc` routes for client navigation, server actions, and streaming SSR with embedded flight data. TypeScript variants of all new virtual modules ship alongside the JS variants.

Also refactors `@fastify/vite`'s development mode to read `viteConfig.environments` directly instead of re-invoking the plugin's `config` hook to discover environments, supporting any number of environments (not just the hardcoded client/ssr pair).
5 changes: 5 additions & 0 deletions e2e/react-rsc/client/actions/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use server'

export async function getServerData() {
return { message: 'Hello from server action!', timestamp: new Date().toISOString() }
}
12 changes: 12 additions & 0 deletions e2e/react-rsc/client/actions/increment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use server'

export async function increment(prevState, formData) {
// When called via useActionState: increment(prevState, formData)
// prevState is the previous { count } value, formData is the form fields.
// When called via progressive enhancement (no-JS form POST): increment(formData)
// formData contains the form fields, prevState is undefined.
const prev = prevState?.count ?? 0
const fd = formData ?? prevState
const current = parseInt(fd?.get?.('count') ?? prev, 10)
return { count: current + 1 }
}
18 changes: 18 additions & 0 deletions e2e/react-rsc/client/components/counter-form.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client'

import { useActionState } from 'react'

export default function CounterForm({ incrementAction }) {
const [result, formAction, isPending] = useActionState(incrementAction, { count: 0 })

return (
<form action={formAction}>
<p>
Server count: <output>{result.count}</output>
</p>
<button type="submit" disabled={isPending}>
{isPending ? 'Incrementing...' : 'Increment'}
</button>
</form>
)
}
15 changes: 15 additions & 0 deletions e2e/react-rsc/client/components/counter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'

import { useState } from 'react'

export default function Counter() {
const [count, setCount] = useState(0)

return (
<div>
<p>Client count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
<button onClick={() => setCount((c) => c - 1)}>-</button>
</div>
)
}
24 changes: 24 additions & 0 deletions e2e/react-rsc/client/components/server-data.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { getServerData } from '../actions/data.js'

export function ServerDataButton() {
const [data, setData] = useState(null)

const handleClick = async () => {
const result = await getServerData()
setData(result)
}

return (
<div>
<button onClick={handleClick}>Fetch Server Data</button>
{data && (
<output>
<p>{data.message}</p>
<p>Timestamp: {data.timestamp}</p>
</output>
)}
</div>
)
}
13 changes: 13 additions & 0 deletions e2e/react-rsc/client/components/state-display.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { useRouteContext } from '@fastify/react/client'

export default function StateDisplay() {
const { snapshot } = useRouteContext()
return (
<div className="valtio-state">
<p data-testid="valtio-count">Count: {snapshot.count}</p>
<p data-testid="valtio-message">Message: {snapshot.message}</p>
</div>
)
}
22 changes: 22 additions & 0 deletions e2e/react-rsc/client/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Server context module for the RSC e2e fixture.
*
* The `state()` function seeds the initial Valtio state object that flows
* through req.route.state → rsc-handler → rsc-entry's ValtioHydrator → client.
* It returns a plain object — the Valtio proxy() wrapping happens on the
* client side in the ValtioHydrator component (and in core.jsx for non-RSC routes).
*/

export function state() {
return {
count: 42,
message: 'Hello from Valtio!',
}
}

/**
* Default context initializer (required by the runtime).
*/
export default async function init() {
// No additional setup needed for this fixture
}
11 changes: 11 additions & 0 deletions e2e/react-rsc/client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<title>RSC e2e</title>
</head>
<body>
<div id="root"><!-- element --></div>
<script type="module" src="$app/mount.js"></script>
<!-- hydration -->
</body>
</html>
13 changes: 13 additions & 0 deletions e2e/react-rsc/client/layouts/auth.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function AuthLayout({ children }) {
// In a real app, the Fastify preHandler would authenticate
// and the auth state would flow through the render context.
// This layout wraps authenticated routes.
return (
<div className="auth-layout">
<nav>
<p>Authenticated area</p>
</nav>
<main>{children}</main>
</div>
)
}
3 changes: 3 additions & 0 deletions e2e/react-rsc/client/layouts/default.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function DefaultLayout({ children }) {
return <main>{children}</main>
}
17 changes: 17 additions & 0 deletions e2e/react-rsc/client/pages/actions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const rsc = true

import CounterForm from '../components/counter-form.jsx'

export default async function ActionsPage() {
const { increment } = await import('../actions/increment.js')
return (
<article>
<h1>RSC Server Actions</h1>
<CounterForm incrementAction={increment} />
</article>
)
}

export function getMeta() {
return { title: 'RSC Server Actions' }
}
15 changes: 15 additions & 0 deletions e2e/react-rsc/client/pages/auth-page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const rsc = true
export const layout = 'auth'

export default async function AuthenticatedPage() {
return (
<section>
<h2>Authenticated Route</h2>
<p>This route uses the auth layout wrapper.</p>
</section>
)
}

export function getMeta() {
return { title: 'Authenticated' }
}
17 changes: 17 additions & 0 deletions e2e/react-rsc/client/pages/data-action.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const rsc = true

import { ServerDataButton } from '../components/server-data.jsx'

export default async function DataActionsPage() {
return (
<section>
<h2>Data Server Action</h2>
<p>Click the button to fetch data from a server action:</p>
<ServerDataButton />
</section>
)
}

export function getMeta() {
return { title: 'Data Action' }
}
5 changes: 5 additions & 0 deletions e2e/react-rsc/client/pages/error.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const rsc = true

export default async function ErrorPage() {
throw new Error('RSC Server Error - intentional for testing')
}
45 changes: 45 additions & 0 deletions e2e/react-rsc/client/pages/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export function getMeta() {
return {
title: 'RSC e2e - Home',
}
}

export default function Index() {
return (
<div>
<h1>RSC e2e - Home</h1>
<p>This is a non-RSC page (mixed mode test)</p>
<nav>
<ul>
<li>
<a href="/rsc-page">RSC Page</a>
</li>
<li>
<a href="/rsc-client">RSC Client</a>
</li>
<li>
<a href="/actions">RSC Actions</a>
</li>
<li>
<a href="/error">RSC Error</a>
</li>
<li>
<a href="/auth-page">Auth Page</a>
</li>
<li>
<a href="/data-action">Data Action</a>
</li>
<li>
<a href="/using-data">Using Data</a>
</li>
<li>
<a href="/using-store">Using Store</a>
</li>
<li>
<a href="/streaming">Streaming</a>
</li>
</ul>
</nav>
</div>
)
}
19 changes: 19 additions & 0 deletions e2e/react-rsc/client/pages/rsc-client.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Counter from '../components/counter.jsx'

export const rsc = true

export default async function RscClientPage() {
return (
<article>
<h1>RSC Client Component Demo</h1>
<p>Below is a &apos;use client&apos; interactive component rendered inside an RSC page:</p>
<Counter />
</article>
)
}

export function getMeta() {
return {
title: 'RSC Client Demo',
}
}
18 changes: 18 additions & 0 deletions e2e/react-rsc/client/pages/rsc-page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const rsc = true

export default async function RscPage() {
return (
<article>
<h1>RSC Page</h1>
<p>Server-rendered timestamp: {new Date().toISOString()}</p>
<p>This content is rendered on the server.</p>
</article>
)
}

export function getMeta() {
return {
title: 'RSC Page',
description: 'A server-rendered RSC page',
}
}
24 changes: 24 additions & 0 deletions e2e/react-rsc/client/pages/streaming.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Suspense } from 'react'

export const rsc = true

async function SlowComponent() {
await new Promise((resolve) => setTimeout(resolve, 500))
return <p>This loaded after 500ms (streamed)</p>
}

export default async function StreamingPage() {
return (
<section>
<h2>Streaming SSR</h2>
<p>This content renders immediately.</p>
<Suspense fallback={<p>Loading slow content...</p>}>
<SlowComponent />
</Suspense>
</section>
)
}

export function getMeta() {
return { title: 'Streaming' }
}
22 changes: 22 additions & 0 deletions e2e/react-rsc/client/pages/using-data.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const rsc = true

export default async function UsingData() {
// Simulate server-side data fetching
const data = await new Promise((resolve) =>
setTimeout(() => resolve({ items: ['Item A', 'Item B', 'Item C'] }), 10),
)
return (
<>
<h2>Data Fetching in RSC</h2>
<ul>
{data.items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</>
)
}

export function getMeta() {
return { title: 'Using Data' }
}
20 changes: 20 additions & 0 deletions e2e/react-rsc/client/pages/using-store.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import StateDisplay from '../components/state-display.jsx'

export const rsc = true

export default async function UsingStore() {
return (
<section>
<h2>Valtio State Management</h2>
<p>
State is seeded from server context.js, threaded through the RSC Flight protocol via
ValtioHydrator, and displayed client-side.
</p>
<StateDisplay />
</section>
)
}

export function getMeta() {
return { title: 'Using Store' }
}
Loading
Loading