diff --git a/packages/plugin-rsc/e2e/browser.test.ts b/packages/plugin-rsc/e2e/browser.test.ts new file mode 100644 index 000000000..3b5898047 --- /dev/null +++ b/packages/plugin-rsc/e2e/browser.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { defineStarterTest } from './starter' +import path from 'node:path' +import fs from 'node:fs' + +// Webkit fails by +// > TypeError: ReadableByteStreamController is not implemented +test.skip(({ browserName }) => browserName === 'webkit') + +test.describe('dev-browser', () => { + const f = useFixture({ root: 'examples/browser', mode: 'dev' }) + defineStarterTest(f, 'no-ssr') +}) + +test.describe('build-browser', () => { + const f = useFixture({ root: 'examples/browser', mode: 'build' }) + defineStarterTest(f, 'no-ssr') + + test('no ssr build', () => { + expect(fs.existsSync(path.join(f.root, 'dist/ssr'))).toBe(false) + }) +}) diff --git a/packages/plugin-rsc/examples/browser/README.md b/packages/plugin-rsc/examples/browser/README.md new file mode 100644 index 000000000..84f3c673e --- /dev/null +++ b/packages/plugin-rsc/examples/browser/README.md @@ -0,0 +1,3 @@ +# `rsc` environment on browser + +See also https://github.com/hi-ogawa/vite-rsc-browser-example/ diff --git a/packages/plugin-rsc/examples/browser/index.html b/packages/plugin-rsc/examples/browser/index.html new file mode 100644 index 000000000..590485e64 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/index.html @@ -0,0 +1,13 @@ + + + + + RSC on Browser + + + + + +
+ + diff --git a/packages/plugin-rsc/examples/browser/lib/dev-proxy.ts b/packages/plugin-rsc/examples/browser/lib/dev-proxy.ts new file mode 100644 index 000000000..0a08e6995 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/lib/dev-proxy.ts @@ -0,0 +1,32 @@ +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +const runner = new ModuleRunner( + { + sourcemapInterceptor: false, + transport: { + invoke: async (payload) => { + const response = await fetch( + '/@vite/invoke-rsc?' + + new URLSearchParams({ + data: JSON.stringify(payload), + }), + ) + return response.json() + }, + }, + hmr: false, + }, + new ESModulesEvaluator(), +) + +export default new Proxy( + {}, + { + get(_target, p, _receiver) { + return async (...args: any[]) => { + const module = await runner.import('/src/framework/entry.rsc') + return module.default[p](...args) + } + }, + }, +) diff --git a/packages/plugin-rsc/examples/browser/lib/plugin.ts b/packages/plugin-rsc/examples/browser/lib/plugin.ts new file mode 100644 index 000000000..c12582c01 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/lib/plugin.ts @@ -0,0 +1,105 @@ +import { rmSync } from 'node:fs' +import path from 'node:path' +import { normalizePath, type Plugin } from 'vite' + +export default function vitePluginRscBrowser(): Plugin[] { + return [ + { + name: 'rsc-browser', + config() { + return { + appType: 'spa', + environments: { + client: { + build: { + emptyOutDir: false, + }, + }, + // TODO: server build is not hashed + rsc: { + build: { + outDir: 'dist/client/__server', + }, + keepProcessEnv: false, + resolve: { + noExternal: true, + }, + optimizeDeps: { + esbuildOptions: { + platform: 'neutral', + }, + }, + }, + }, + rsc: { + serverHandler: false, + }, + } + }, + configResolved(config) { + // avoid globalThis.AsyncLocalStorage injection in browser mode + const plugin = config.plugins.find( + (p) => p.name === 'rsc:inject-async-local-storage', + ) + delete plugin!.transform + }, + buildApp: { + order: 'pre', + async handler() { + // clean up nested outDir + rmSync('./dist', { recursive: true, force: true }) + }, + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? '/', 'https://any.local') + if (url.pathname === '/@vite/invoke-rsc') { + const payload = JSON.parse(url.searchParams.get('data')!) + const result = + await server.environments['rsc']!.hot.handleInvoke(payload) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + return + } + next() + }) + }, + }, + { + name: 'rsc-browser:load-rsc', + resolveId(source) { + if (source === 'virtual:vite-rsc-browser/load-rsc') { + if (this.environment.mode === 'dev') { + return this.resolve('/lib/dev-proxy') + } + return { id: source, external: true } + } + }, + renderChunk(code, chunk) { + if (code.includes('virtual:vite-rsc-browser/load-rsc')) { + const config = this.environment.getTopLevelConfig() + const replacement = normalizeRelativePath( + path.relative( + path.join( + config.environments.client.build.outDir, + chunk.fileName, + '..', + ), + path.join(config.environments.rsc.build.outDir, 'index.js'), + ), + ) + code = code.replaceAll( + 'virtual:vite-rsc-browser/load-rsc', + () => replacement, + ) + return { code } + } + }, + }, + ] +} + +function normalizeRelativePath(s: string): string { + s = normalizePath(s) + return s[0] === '.' ? s : './' + s +} diff --git a/packages/plugin-rsc/examples/browser/lib/runtime.ts b/packages/plugin-rsc/examples/browser/lib/runtime.ts new file mode 100644 index 000000000..5c505946b --- /dev/null +++ b/packages/plugin-rsc/examples/browser/lib/runtime.ts @@ -0,0 +1,3 @@ +export function loadEntryRsc() { + return import('virtual:vite-rsc-browser/load-rsc' as any) +} diff --git a/packages/plugin-rsc/examples/browser/package.json b/packages/plugin-rsc/examples/browser/package.json new file mode 100644 index 000000000..17c85382b --- /dev/null +++ b/packages/plugin-rsc/examples/browser/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-browser", + "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/public/vite.svg b/packages/plugin-rsc/examples/browser/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/browser/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser/src/action.tsx b/packages/plugin-rsc/examples/browser/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/browser/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/src/assets/react.svg b/packages/plugin-rsc/examples/browser/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/browser/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser/src/client.tsx b/packages/plugin-rsc/examples/browser/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/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/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/browser/src/framework/entry.browser.tsx new file mode 100644 index 000000000..ee086c8b1 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/src/framework/entry.browser.tsx @@ -0,0 +1,135 @@ +import 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' +import { loadEntryRsc } from '../../lib/runtime' + +async function fetchRsc(request: Request): Promise { + const module = await loadEntryRsc() + return module.default.fetch(request) +} + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromFetch( + fetchRsc(new Request(window.location.href)), + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await createFromFetch( + fetchRsc(new Request(window.location.href)), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetchRsc( + new Request(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + ), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + createRoot(document.body).render(browserRoot) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/browser/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/browser/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..8d0195b9f --- /dev/null +++ b/packages/plugin-rsc/examples/browser/src/framework/entry.rsc.tsx @@ -0,0 +1,60 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +async function handler(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', + }, + }) +} + +export default { + fetch: handler, +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/browser/src/index.css b/packages/plugin-rsc/examples/browser/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/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/src/root.tsx b/packages/plugin-rsc/examples/browser/src/root.tsx new file mode 100644 index 000000000..9baa7b9c2 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/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/tsconfig.json b/packages/plugin-rsc/examples/browser/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/browser/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/vite.config.ts b/packages/plugin-rsc/examples/browser/vite.config.ts new file mode 100644 index 000000000..47e029848 --- /dev/null +++ b/packages/plugin-rsc/examples/browser/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import rscBrowser from './lib/plugin' + +export default defineConfig({ + plugins: [ + react(), + rscBrowser(), + rsc({ + entries: { + rsc: './src/framework/entry.rsc.tsx', + }, + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f77eeb4..886392989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,31 @@ importers: specifier: ^4.43.0 version: 4.43.0 + packages/plugin-rsc/examples/browser: + 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/browser-mode: dependencies: react: