diff --git a/packages/plugin-rsc/e2e/client-first.test.ts b/packages/plugin-rsc/e2e/client-first.test.ts new file mode 100644 index 000000000..8fb87503f --- /dev/null +++ b/packages/plugin-rsc/e2e/client-first.test.ts @@ -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') + }) +} diff --git a/packages/plugin-rsc/examples/client-first/README.md b/packages/plugin-rsc/examples/client-first/README.md new file mode 100644 index 000000000..be0927b8b --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/README.md @@ -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. diff --git a/packages/plugin-rsc/examples/client-first/package.json b/packages/plugin-rsc/examples/client-first/package.json new file mode 100644 index 000000000..f07f918db --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/package.json @@ -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" + } +} diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx new file mode 100644 index 000000000..70e7d6eeb --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx @@ -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, ) +} + +main() diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..ad6d550fc --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx @@ -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> { + 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() +} diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..e0b536e32 --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx @@ -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(, { 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) diff --git a/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx b/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx new file mode 100644 index 000000000..5022c7a67 --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx @@ -0,0 +1,26 @@ +export type RscFnCaller = (id: string, args: unknown[]) => Promise +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( + id: string, + handler: (...args: TArgs) => Promise, +) { + const promises = new Map>() + const rscFn = (...args: TArgs) => { + const key = JSON.stringify(args) + let promise = promises.get(key) + if (!promise) { + promise = rscFnCaller(id, args) as Promise + promises.set(key, promise) + } + return promise + } + rscFn.handler = handler + return rscFn +} diff --git a/packages/plugin-rsc/examples/client-first/src/root.tsx b/packages/plugin-rsc/examples/client-first/src/root.tsx new file mode 100644 index 000000000..5959f0452 --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/root.tsx @@ -0,0 +1,15 @@ +import { Page } from './routes/page' + +export function Root() { + return ( + + + + Client-first RSC + + + + + + ) +} diff --git a/packages/plugin-rsc/examples/client-first/src/routes/page.tsx b/packages/plugin-rsc/examples/client-first/src/routes/page.tsx new file mode 100644 index 000000000..c56e008f0 --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/src/routes/page.tsx @@ -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 () => ( +

server: baseline

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

client: baseline

+ {serverMessage} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/client-first/tsconfig.json b/packages/plugin-rsc/examples/client-first/tsconfig.json new file mode 100644 index 000000000..ebc1ee8cc --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/tsconfig.json @@ -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"] +} diff --git a/packages/plugin-rsc/examples/client-first/vite.config.ts b/packages/plugin-rsc/examples/client-first/vite.config.ts new file mode 100644 index 000000000..075202888 --- /dev/null +++ b/packages/plugin-rsc/examples/client-first/vite.config.ts @@ -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, + } + } + }, + } +} diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index a57341245..9253de54f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -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 [] + } } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67677d101..fdd5af7b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,31 @@ importers: specifier: ^8.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/client-first: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel':