Skip to content

Commit f0e27eb

Browse files
sreetamdashi-ogawaOpenCode
authored
fix(rsc): keep client HMR for client modules co-located with rsc-graph code (#1248)
Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com> Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent 372a0e7 commit f0e27eb

13 files changed

Lines changed: 364 additions & 2 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from '@playwright/test'
2+
import { type Fixture, useFixture } from './fixture'
3+
import { expectNoPageError, waitForHydration } from './helper'
4+
5+
test.describe('dev', () => {
6+
const f = useFixture({ root: 'examples/client-first', mode: 'dev' })
7+
defineTests(f)
8+
9+
test('client HMR for a module shared with the RSC graph', async ({
10+
page,
11+
}) => {
12+
using _ = expectNoPageError(page)
13+
await page.goto(f.url())
14+
await waitForHydration(page)
15+
16+
const counter = page.getByTestId('count')
17+
await counter.click()
18+
await expect(counter).toHaveText('count: 1')
19+
20+
const editor = f.createEditor('src/routes/page.tsx')
21+
editor.edit((source) =>
22+
source.replace('client: baseline', 'client: edited'),
23+
)
24+
25+
await expect(page.getByTestId('client')).toHaveText('client: edited')
26+
await expect(counter).toHaveText('count: 1')
27+
28+
editor.reset()
29+
await expect(page.getByTestId('client')).toHaveText('client: baseline')
30+
await expect(counter).toHaveText('count: 1')
31+
})
32+
})
33+
34+
test.describe('build', () => {
35+
const f = useFixture({ root: 'examples/client-first', mode: 'build' })
36+
defineTests(f)
37+
})
38+
39+
function defineTests(f: Fixture) {
40+
test('renders an RSC function result in a client-owned page', async ({
41+
page,
42+
}) => {
43+
using _ = expectNoPageError(page)
44+
await page.goto(f.url())
45+
46+
await expect(page.getByTestId('client')).toHaveText('client: baseline')
47+
await expect(page.getByTestId('server')).toHaveText('server: baseline')
48+
49+
await waitForHydration(page)
50+
const counter = page.getByTestId('count')
51+
await counter.click()
52+
await expect(counter).toHaveText('count: 1')
53+
})
54+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Client-first RSC
2+
3+
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.
4+
5+
The framework pieces are intentionally direct:
6+
7+
- `routes/page.tsx` co-locates the page and RSC-function handler.
8+
- `runtime.tsx` creates a callable RSC-function stub and caches its promise for Suspense.
9+
- `entry.rsc.tsx` executes RSC functions and encodes their results as Flight streams.
10+
- `entry.ssr.tsx` configures an in-process RSC caller before rendering HTML.
11+
- `entry.browser.tsx` configures an HTTP RSC caller before hydrating the same page.
12+
13+
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.
14+
15+
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.
16+
17+
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.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-client-first",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.8",
14+
"react-dom": "^19.2.8"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.17",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-react": "latest",
20+
"@vitejs/plugin-rsc": "latest",
21+
"vite": "^8.1.4"
22+
}
23+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createFromFetch } from '@vitejs/plugin-rsc/browser'
2+
import { hydrateRoot } from 'react-dom/client'
3+
import { Root } from '../root'
4+
import { setRscFnCaller, type RscFnCaller } from './runtime'
5+
6+
function main() {
7+
const callRscFn: RscFnCaller = async (id, args) => {
8+
return createFromFetch(
9+
fetch('/__rsc-function', {
10+
method: 'POST',
11+
headers: {
12+
'content-type': 'application/json',
13+
'x-rsc-function-id': id,
14+
},
15+
body: JSON.stringify(args),
16+
}),
17+
)
18+
}
19+
20+
setRscFnCaller(callRscFn)
21+
22+
hydrateRoot(document, <Root />)
23+
}
24+
25+
main()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
2+
import { getServerMessage } from '../routes/page'
3+
4+
export default async function handler(request: Request) {
5+
const url = new URL(request.url)
6+
7+
// handle rsc fetch calls by browser clients
8+
if (url.pathname === '/__rsc-function') {
9+
const id = request.headers.get('x-rsc-function-id')
10+
if (!id) return new Response('Missing RSC function id', { status: 400 })
11+
12+
const args = (await request.json()) as unknown[]
13+
const stream = await executeRscFn(id, args)
14+
return new Response(stream, {
15+
headers: { 'content-type': 'text/x-component;charset=utf-8' },
16+
})
17+
}
18+
19+
// fully delegate to SSR
20+
const ssrEntry = await import.meta.viteRsc.loadModule<
21+
typeof import('./entry.ssr')
22+
>('ssr', 'index')
23+
return new Response(await ssrEntry.renderHtml(), {
24+
headers: { 'content-type': 'text/html' },
25+
})
26+
}
27+
28+
// hard-coded RSC function registry for demo simplicity
29+
// TODO: Replace this with a split-module resolver: encoded module IDs with lazy
30+
// loading in dev, and a generated manifest in build.
31+
const rscFunctions = { getServerMessage: getServerMessage.handler }
32+
33+
// The browser reaches this executor over HTTP, while SSR invokes it directly
34+
// through Vite's RSC environment to avoid an internal HTTP round trip.
35+
export async function executeRscFn(
36+
id: string,
37+
args: unknown[],
38+
): Promise<ReadableStream<Uint8Array>> {
39+
const rscFn = rscFunctions[id as keyof typeof rscFunctions] as
40+
| ((...args: unknown[]) => unknown)
41+
| undefined
42+
if (!rscFn) {
43+
throw new Error(`Unknown RSC function: ${id}`)
44+
}
45+
46+
const result = await rscFn(...args)
47+
return renderToReadableStream(result)
48+
}
49+
50+
if (import.meta.hot) {
51+
import.meta.hot.accept()
52+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
2+
import { renderToReadableStream } from 'react-dom/server.edge'
3+
import { Root } from '../root'
4+
import { setRscFnCaller, type RscFnCaller } from './runtime'
5+
6+
export async function renderHtml() {
7+
const bootstrapScriptContent =
8+
await import.meta.viteRsc.loadBootstrapScriptContent('index')
9+
return renderToReadableStream(<Root />, { bootstrapScriptContent })
10+
}
11+
12+
// SSR resolves RSC functions in-process because it already runs beside the RSC
13+
// environment. Browser calls use HTTP instead.
14+
const callRscFn: RscFnCaller = async (id, args) => {
15+
const rscEntry = await import.meta.viteRsc.loadModule<
16+
typeof import('./entry.rsc')
17+
>('rsc', 'index')
18+
const stream = await rscEntry.executeRscFn(id, args)
19+
return createFromReadableStream(stream)
20+
}
21+
22+
setRscFnCaller(callRscFn)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type RscFnCaller = (id: string, args: unknown[]) => Promise<unknown>
2+
let rscFnCaller: RscFnCaller
3+
4+
export function setRscFnCaller(callerImpl: RscFnCaller) {
5+
rscFnCaller = callerImpl
6+
}
7+
8+
// React use() requires the same promise when a suspended render restarts. This
9+
// minimal argument-keyed cache is module-scoped, including during SSR.
10+
export function createRscFn<TArgs extends unknown[], TResult>(
11+
id: string,
12+
handler: (...args: TArgs) => Promise<TResult>,
13+
) {
14+
const promises = new Map<string, Promise<TResult>>()
15+
const rscFn = (...args: TArgs) => {
16+
const key = JSON.stringify(args)
17+
let promise = promises.get(key)
18+
if (!promise) {
19+
promise = rscFnCaller(id, args) as Promise<TResult>
20+
promises.set(key, promise)
21+
}
22+
return promise
23+
}
24+
rscFn.handler = handler
25+
return rscFn
26+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Page } from './routes/page'
2+
3+
export function Root() {
4+
return (
5+
<html lang="en">
6+
<head>
7+
<meta charSet="UTF-8" />
8+
<title>Client-first RSC</title>
9+
</head>
10+
<body>
11+
<Page />
12+
</body>
13+
</html>
14+
)
15+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { use, useState } from 'react'
2+
import { createRscFn } from '../framework/runtime'
3+
4+
// TODO: Split this module via query params so browser/SSR retain the caller and
5+
// Page while RSC receives the handler. This temporary export-only transform
6+
// enables Fast Refresh but does not remove the handler from caller bundles.
7+
/* @rsc-only-export */
8+
export const getServerMessage = createRscFn('getServerMessage', async () => (
9+
<p data-testid="server">server: baseline</p>
10+
))
11+
12+
export function Page() {
13+
const serverMessage = use(getServerMessage())
14+
const [count, setCount] = useState(0)
15+
16+
return (
17+
<main>
18+
<h1 data-testid="client">client: baseline</h1>
19+
{serverMessage}
20+
<button
21+
data-testid="count"
22+
onClick={() => setCount((value) => value + 1)}
23+
>
24+
count: {count}
25+
</button>
26+
</main>
27+
)
28+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
6+
"allowJs": false,
7+
"skipLibCheck": true,
8+
"esModuleInterop": true,
9+
"allowSyntheticDefaultImports": true,
10+
"strict": true,
11+
"forceConsistentCasingInFileNames": true,
12+
"module": "ESNext",
13+
"moduleResolution": "Bundler",
14+
"resolveJsonModule": true,
15+
"isolatedModules": true,
16+
"noEmit": true,
17+
"jsx": "react-jsx",
18+
"types": ["vite/client", "@vitejs/plugin-rsc/types"]
19+
},
20+
"include": ["src", "vite.config.ts"]
21+
}

0 commit comments

Comments
 (0)