diff --git a/packages/plugin-rsc/examples/browser-mode2/README.md b/packages/plugin-rsc/examples/browser-mode2/README.md new file mode 100644 index 000000000..83e723d1a --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/README.md @@ -0,0 +1 @@ +[examples/no-ssr](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/no-ssr) with `rsc` environment running on browser via module runner diff --git a/packages/plugin-rsc/examples/browser-mode2/index.html b/packages/plugin-rsc/examples/browser-mode2/index.html new file mode 100644 index 000000000..ec960c1a5 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/index.html @@ -0,0 +1,13 @@ + + + + + RSC Browser Mode 2 + + + + + +
+ + diff --git a/packages/plugin-rsc/examples/browser-mode2/package.json b/packages/plugin-rsc/examples/browser-mode2/package.json new file mode 100644 index 000000000..9d9e3fa38 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-browser-mode2", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "vite": "^7.1.10" + } +} diff --git a/packages/plugin-rsc/examples/browser-mode2/public/vite.svg b/packages/plugin-rsc/examples/browser-mode2/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser-mode2/src/action.tsx b/packages/plugin-rsc/examples/browser-mode2/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/assets/react.svg b/packages/plugin-rsc/examples/browser-mode2/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser-mode2/src/client.tsx b/packages/plugin-rsc/examples/browser-mode2/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.browser.tsx new file mode 100644 index 000000000..0e5075bcb --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.browser.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import type { RscPayload } from './entry.rsc' + +let fetchServer: typeof import('./entry.rsc').fetchServer + +export function initialize(options: { fetchServer: typeof fetchServer }) { + fetchServer = options.fetchServer +} + +export async function main() { + let setPayload: (v: RscPayload) => void + + // Set server callback BEFORE processing the initial payload + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetchServer( + new Request(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + ), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + const initialPayload = await createFromFetch( + fetchServer(new Request(window.location.href)), + ) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + return payload.root + } + + const browserRoot = ( + + + + ) + createRoot(document.body).render(browserRoot) +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..1820c6e66 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/framework/entry.rsc.tsx @@ -0,0 +1,68 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, + setRequireModule, +} from '@vitejs/plugin-rsc/react/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +export function initialize() { + setRequireModule({ + load: async (id) => { + return import(/* @vite-ignore */ id) + }, + }) +} + +export async function fetchServer(request: Request): Promise { + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const rscPayload: RscPayload = { root: , formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) +} + +// Default export for no-ssr mode compatibility +export default fetchServer + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/framework/load-client-dev.tsx b/packages/plugin-rsc/examples/browser-mode2/src/framework/load-client-dev.tsx new file mode 100644 index 000000000..8ca40ae43 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/framework/load-client-dev.tsx @@ -0,0 +1,25 @@ +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +export default async function loadClient() { + const runner = new ModuleRunner( + { + sourcemapInterceptor: false, + transport: { + invoke: async (payload) => { + const response = await fetch( + '/@vite/invoke-react-client?' + + new URLSearchParams({ + data: JSON.stringify(payload), + }), + ) + return response.json() + }, + }, + hmr: false, + }, + new ESModulesEvaluator(), + ) + return await runner.import( + '/src/framework/entry.browser.tsx', + ) +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/framework/main.tsx b/packages/plugin-rsc/examples/browser-mode2/src/framework/main.tsx new file mode 100644 index 000000000..050ed1d08 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/framework/main.tsx @@ -0,0 +1,11 @@ +import * as server from './entry.rsc' +import loadClient from 'virtual:vite-rsc-browser-mode2/load-client' + +async function main() { + const client = await loadClient() + server.initialize() + client.initialize({ fetchServer: server.fetchServer }) + await client.main() +} + +main() diff --git a/packages/plugin-rsc/examples/browser-mode2/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/browser-mode2/src/framework/virtual.d.ts new file mode 100644 index 000000000..2f251e6fe --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/framework/virtual.d.ts @@ -0,0 +1,4 @@ +declare module 'virtual:vite-rsc-browser-mode2/load-client' { + const loadClient: () => Promise + export default loadClient +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/index.css b/packages/plugin-rsc/examples/browser-mode2/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/browser-mode2/src/root.tsx b/packages/plugin-rsc/examples/browser-mode2/src/root.tsx new file mode 100644 index 000000000..9baa7b9c2 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/src/root.tsx @@ -0,0 +1,44 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root() { + return +} + +function App() { + return ( +
+ +

Vite + RSC

+
+ +
+
+
+ +
+
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/browser-mode2/tsconfig.json b/packages/plugin-rsc/examples/browser-mode2/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/browser-mode2/vite.config.ts b/packages/plugin-rsc/examples/browser-mode2/vite.config.ts new file mode 100644 index 000000000..5123022b1 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode2/vite.config.ts @@ -0,0 +1,124 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defaultClientConditions, defineConfig, type Plugin } from 'vite' +import fsp from 'node:fs/promises' + +export default defineConfig({ + plugins: [ + spaPlugin(), + react(), + rsc({ + entries: { + rsc: './src/framework/entry.rsc.tsx', + }, + }), + rscBrowserMode2Plugin(), + ], + environments: { + client: { + build: { + minify: false, + }, + }, + }, +}) + +function spaPlugin(): Plugin[] { + // serve index.html before rsc server + return [ + { + name: 'serve-spa', + configureServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + try { + if (req.headers.accept?.includes('text/html')) { + const html = await fsp.readFile('index.html', 'utf-8') + const transformed = await server.transformIndexHtml('/', html) + res.setHeader('Content-type', 'text/html') + res.setHeader('Vary', 'accept') + res.end(transformed) + return + } + } catch (error) { + next(error) + return + } + next() + }) + } + }, + }, + ] +} + +function rscBrowserMode2Plugin(): Plugin[] { + return [ + { + name: 'rsc-browser-mode2', + config() { + return { + environments: { + react_client: { + resolve: { + noExternal: true, + }, + build: { + outDir: 'dist/react_client', + copyPublicDir: false, + emitAssets: true, + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, + } + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? '/', 'https://any.local') + if (url.pathname === '/@vite/invoke-react-client') { + const payload = JSON.parse(url.searchParams.get('data')!) + const result = + await server.environments['react_client']!.hot.handleInvoke( + payload, + ) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + return + } + next() + }) + }, + hotUpdate(ctx) { + if (this.environment.name === 'react_client') { + if (ctx.modules.length > 0) { + ctx.server.environments.client.hot.send({ + type: 'full-reload', + path: ctx.file, + }) + } + } + }, + }, + { + name: 'rsc-browser-mode2:load-client', + resolveId(source) { + if (source === 'virtual:vite-rsc-browser-mode2/load-client') { + if (this.environment.mode === 'dev') { + return this.resolve('/src/framework/load-client-dev') + } + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode2/load-client') { + return `export default async () => import("/dist/react_client/index.js")` + } + }, + }, + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0a7a5c2..d5474f240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,31 @@ importers: specifier: ^7.1.10 version: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + packages/plugin-rsc/examples/browser-mode2: + dependencies: + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + vite: + specifier: ^7.1.10 + version: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + packages/plugin-rsc/examples/e2e: devDependencies: '@vitejs/plugin-react':