Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions packages/plugin-rsc/e2e/client-first.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test'
import { type Fixture, useFixture } from './fixture'
import { expectNoPageError, waitForHydration } from './helper'

test.describe('dev', () => {
const f = useFixture({ root: 'examples/client-first', mode: 'dev' })
defineTests(f)

test('client HMR for a module shared with the RSC graph', async ({
page,
}) => {
using _ = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)

const counter = page.getByTestId('count')
await counter.click()
await expect(counter).toHaveText('count: 1')

const editor = f.createEditor('src/routes/page.tsx')
editor.edit((source) =>
source.replace('client: baseline', 'client: edited'),
)

await expect(page.getByTestId('client')).toHaveText('client: edited')
await expect(counter).toHaveText('count: 1')

editor.reset()
await expect(page.getByTestId('client')).toHaveText('client: baseline')
await expect(counter).toHaveText('count: 1')
})
})

test.describe('build', () => {
const f = useFixture({ root: 'examples/client-first', mode: 'build' })
defineTests(f)
})

function defineTests(f: Fixture) {
test('renders an RSC function result in a client-owned page', async ({
page,
}) => {
using _ = expectNoPageError(page)
await page.goto(f.url())

await expect(page.getByTestId('client')).toHaveText('client: baseline')
await expect(page.getByTestId('server')).toHaveText('server: baseline')

await waitForHydration(page)
const counter = page.getByTestId('count')
await counter.click()
await expect(counter).toHaveText('count: 1')
})
}
17 changes: 17 additions & 0 deletions packages/plugin-rsc/examples/client-first/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Client-first RSC

This example sketches the minimum framework machinery for rendering an RSC value inside a client-owned page. The page reads a cached RSC-function promise with React `use` while retaining ordinary client state.

The framework pieces are intentionally direct:

- `routes/page.tsx` co-locates the page and RSC-function handler.
- `runtime.tsx` creates a callable RSC-function stub and caches its promise for Suspense.
- `entry.rsc.tsx` executes RSC functions and encodes their results as Flight streams.
- `entry.ssr.tsx` configures an in-process RSC caller before rendering HTML.
- `entry.browser.tsx` configures an HTTP RSC caller before hydrating the same page.

For now, `entry.rsc.tsx` imports the co-located handler explicitly. A later module-splitting transform should replace that bridge by moving the handler into the RSC graph while leaving only its caller stub in the SSR and browser graphs.

There is deliberately no SSR-to-browser data handoff yet. SSR and the browser each execute the RSC function independently, which keeps serialization transport separate from the core client-first model.

This example is based on [hi-ogawa/experiments: tanstack-start-rsc](https://github.com/hi-ogawa/experiments/tree/main/tanstack-start-rsc), especially its RSC serialization runtimes and route-level `use` pattern.
23 changes: 23 additions & 0 deletions packages/plugin-rsc/examples/client-first/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@vitejs/plugin-rsc-examples-client-first",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"vite": "^8.1.4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFromFetch } from '@vitejs/plugin-rsc/browser'
import { hydrateRoot } from 'react-dom/client'
import { Root } from '../root'
import { setRscFnCaller, type RscFnCaller } from './runtime'

function main() {
const callRscFn: RscFnCaller = async (id, args) => {
return createFromFetch(
fetch('/__rsc-function', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-rsc-function-id': id,
},
body: JSON.stringify(args),
}),
)
}

setRscFnCaller(callRscFn)

hydrateRoot(document, <Root />)
}

main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
import { getServerMessage } from '../routes/page'

export default async function handler(request: Request) {
const url = new URL(request.url)

// handle rsc fetch calls by browser clients
if (url.pathname === '/__rsc-function') {
const id = request.headers.get('x-rsc-function-id')
if (!id) return new Response('Missing RSC function id', { status: 400 })

const args = (await request.json()) as unknown[]
const stream = await executeRscFn(id, args)
return new Response(stream, {
headers: { 'content-type': 'text/x-component;charset=utf-8' },
})
}

// fully delegate to SSR
const ssrEntry = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr')
>('ssr', 'index')
return new Response(await ssrEntry.renderHtml(), {
headers: { 'content-type': 'text/html' },
})
}

// hard-coded RSC function registry for demo simplicity
// TODO: Replace this with a split-module resolver: encoded module IDs with lazy
// loading in dev, and a generated manifest in build.
const rscFunctions = { getServerMessage: getServerMessage.handler }

// The browser reaches this executor over HTTP, while SSR invokes it directly
// through Vite's RSC environment to avoid an internal HTTP round trip.
export async function executeRscFn(
id: string,
args: unknown[],
): Promise<ReadableStream<Uint8Array>> {
const rscFn = rscFunctions[id as keyof typeof rscFunctions] as
| ((...args: unknown[]) => unknown)
| undefined
if (!rscFn) {
throw new Error(`Unknown RSC function: ${id}`)
}

const result = await rscFn(...args)
return renderToReadableStream(result)
}

if (import.meta.hot) {
import.meta.hot.accept()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
import { renderToReadableStream } from 'react-dom/server.edge'
import { Root } from '../root'
import { setRscFnCaller, type RscFnCaller } from './runtime'

export async function renderHtml() {
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
return renderToReadableStream(<Root />, { bootstrapScriptContent })
}

// SSR resolves RSC functions in-process because it already runs beside the RSC
// environment. Browser calls use HTTP instead.
const callRscFn: RscFnCaller = async (id, args) => {
const rscEntry = await import.meta.viteRsc.loadModule<
typeof import('./entry.rsc')
>('rsc', 'index')
const stream = await rscEntry.executeRscFn(id, args)
return createFromReadableStream(stream)
}

setRscFnCaller(callRscFn)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type RscFnCaller = (id: string, args: unknown[]) => Promise<unknown>
let rscFnCaller: RscFnCaller

export function setRscFnCaller(callerImpl: RscFnCaller) {
rscFnCaller = callerImpl
}

// React use() requires the same promise when a suspended render restarts. This
// minimal argument-keyed cache is module-scoped, including during SSR.
export function createRscFn<TArgs extends unknown[], TResult>(
id: string,
handler: (...args: TArgs) => Promise<TResult>,
) {
const promises = new Map<string, Promise<TResult>>()
const rscFn = (...args: TArgs) => {
const key = JSON.stringify(args)
let promise = promises.get(key)
if (!promise) {
promise = rscFnCaller(id, args) as Promise<TResult>
promises.set(key, promise)
}
return promise
}
rscFn.handler = handler
return rscFn
}
15 changes: 15 additions & 0 deletions packages/plugin-rsc/examples/client-first/src/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Page } from './routes/page'

export function Root() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>Client-first RSC</title>
</head>
<body>
<Page />
</body>
</html>
)
}
28 changes: 28 additions & 0 deletions packages/plugin-rsc/examples/client-first/src/routes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { use, useState } from 'react'
import { createRscFn } from '../framework/runtime'

// TODO: Split this module via query params so browser/SSR retain the caller and
// Page while RSC receives the handler. This temporary export-only transform
// enables Fast Refresh but does not remove the handler from caller bundles.
/* @rsc-only-export */
export const getServerMessage = createRscFn('getServerMessage', async () => (
<p data-testid="server">server: baseline</p>
))

export function Page() {
const serverMessage = use(getServerMessage())
const [count, setCount] = useState(0)

return (
<main>
<h1 data-testid="client">client: baseline</h1>
{serverMessage}
<button
data-testid="count"
onClick={() => setCount((value) => value + 1)}
>
count: {count}
</button>
</main>
)
}
21 changes: 21 additions & 0 deletions packages/plugin-rsc/examples/client-first/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client", "@vitejs/plugin-rsc/types"]
},
"include": ["src", "vite.config.ts"]
}
42 changes: 42 additions & 0 deletions packages/plugin-rsc/examples/client-first/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import react from '@vitejs/plugin-react'
import rsc from '@vitejs/plugin-rsc'
import { defineConfig, type Plugin } from 'vite'

export default defineConfig({
plugins: [
createRscFnPlugin(),
rsc({
entries: {
client: './src/framework/entry.browser.tsx',
ssr: './src/framework/entry.ssr.tsx',
rsc: './src/framework/entry.rsc.tsx',
},
}),
react(),
],
})

// Keep marked RSC functions exported in the RSC environment for registry
// lookup, but make them local in browser/SSR so React sees a component-only
// export boundary and can preserve state during Fast Refresh. This temporary
// transform does not remove handler code from caller bundles.
function createRscFnPlugin(): Plugin {
return {
name: 'client-first:rsc-only-export',
enforce: 'pre',
transform(code) {
if (
this.environment.name !== 'rsc' &&
code.includes('@rsc-only-export')
) {
return {
code: code.replace(
/(\/\* @rsc-only-export \*\/\s*)export\b/g,
'$1 ',
),
map: null,
}
}
},
}
}
16 changes: 14 additions & 2 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,14 +852,26 @@ export default function vitePluginRsc(
const env = ctx.server.environments.rsc!
const mod = env.moduleGraph.getModuleById(ctx.file)
if (mod) {
// Unusually, the same source file can be live in the client graph
// while also present in the RSC graph without a "use client"
// boundary. For example, a client-first framework may extract an
// RSC function handler while treating a component in the same file
// as client code by convention. Refresh style/watch importers, but
// preserve normal client HMR in this case.
let hasNonCssImporter = false
for (const clientMod of ctx.modules) {
for (const importer of clientMod.importers) {
if (importer.id && isCSSRequest(importer.id)) {
if (!importer.id) continue
if (isCSSRequest(importer.id)) {
await this.environment.reloadModule(importer)
} else {
hasNonCssImporter = true
}
}
}
return []
if (!hasNonCssImporter) {
return []
}
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading