From c170403d63d0545be19aa15a47f45a0d63767d85 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:14:06 +0900 Subject: [PATCH 01/13] fix(vite): fix custom services environment and rollupOptions.input --- src/build/vite/env.ts | 2 +- src/build/vite/plugin.ts | 2 +- src/build/vite/types.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index 15c0a5c28e..19979a52de 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -68,7 +68,7 @@ export function createServiceEnvironment( return { consumer: "server", build: { - rollupOptions: { input: serviceConfig.entry }, + rollupOptions: { input: { index: serviceConfig.entry } }, minify: ctx.nitro!.options.minify, sourcemap: ctx.nitro!.options.sourcemap, outDir: join(ctx.nitro!.options.buildDir, "vite/services", name), diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index e5f46e2dc4..ac9d341ad8 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -285,7 +285,7 @@ function nitroService(ctx: NitroPluginContext): VitePlugin { function createContext(pluginConfig: NitroPluginConfig): NitroPluginContext { return { pluginConfig, - services: {}, + services: { ...pluginConfig.services }, _entryPoints: {}, }; } diff --git a/src/build/vite/types.ts b/src/build/vite/types.ts index 9f2d6146a0..8526b1460a 100644 --- a/src/build/vite/types.ts +++ b/src/build/vite/types.ts @@ -21,6 +21,11 @@ export interface NitroPluginConfig extends NitroConfig { */ _nitro?: Nitro; + /** + * Additional Vite environment services to register. + */ + services?: Record; + experimental?: NitroConfig["experimental"] & { vite: { /** From ef74d784642100adfcffac5fb33a583b41363e46 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:17:11 +0900 Subject: [PATCH 02/13] chore: add examples/vite-rsc --- examples/vite-rsc/.gitignore | 2 + examples/vite-rsc/README.md | 40 +++++ examples/vite-rsc/package.json | 24 +++ examples/vite-rsc/public/vite.svg | 1 + examples/vite-rsc/src/action.tsx | 11 ++ examples/vite-rsc/src/assets/react.svg | 1 + examples/vite-rsc/src/client.tsx | 13 ++ .../vite-rsc/src/framework/entry.browser.tsx | 138 ++++++++++++++++++ examples/vite-rsc/src/framework/entry.rsc.tsx | 120 +++++++++++++++ examples/vite-rsc/src/framework/entry.ssr.tsx | 74 ++++++++++ .../vite-rsc/src/framework/error-boundary.tsx | 81 ++++++++++ examples/vite-rsc/src/framework/request.tsx | 58 ++++++++ examples/vite-rsc/src/index.css | 112 ++++++++++++++ examples/vite-rsc/src/root.tsx | 71 +++++++++ examples/vite-rsc/tsconfig.json | 18 +++ examples/vite-rsc/vite.config.ts | 72 +++++++++ pnpm-lock.yaml | 105 +++++++++++++ 17 files changed, 941 insertions(+) create mode 100644 examples/vite-rsc/.gitignore create mode 100644 examples/vite-rsc/README.md create mode 100644 examples/vite-rsc/package.json create mode 100644 examples/vite-rsc/public/vite.svg create mode 100644 examples/vite-rsc/src/action.tsx create mode 100644 examples/vite-rsc/src/assets/react.svg create mode 100644 examples/vite-rsc/src/client.tsx create mode 100644 examples/vite-rsc/src/framework/entry.browser.tsx create mode 100644 examples/vite-rsc/src/framework/entry.rsc.tsx create mode 100644 examples/vite-rsc/src/framework/entry.ssr.tsx create mode 100644 examples/vite-rsc/src/framework/error-boundary.tsx create mode 100644 examples/vite-rsc/src/framework/request.tsx create mode 100644 examples/vite-rsc/src/index.css create mode 100644 examples/vite-rsc/src/root.tsx create mode 100644 examples/vite-rsc/tsconfig.json create mode 100644 examples/vite-rsc/vite.config.ts diff --git a/examples/vite-rsc/.gitignore b/examples/vite-rsc/.gitignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/examples/vite-rsc/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/vite-rsc/README.md b/examples/vite-rsc/README.md new file mode 100644 index 0000000000..cd11171061 --- /dev/null +++ b/examples/vite-rsc/README.md @@ -0,0 +1,40 @@ +# Vite + RSC + +This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +``` + +## API usage + +See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. + +- [`vite.config.ts`](./vite.config.ts) + - `@vitejs/plugin-rsc/plugin` +- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) + - `@vitejs/plugin-rsc/rsc` + - `import.meta.viteRsc.loadModule` +- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) + - `@vitejs/plugin-rsc/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` + - `rsc-html-stream/server` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `rsc-html-stream/client` + +## Notes + +- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework. +- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally. + +## Deployment + +See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) diff --git a/examples/vite-rsc/package.json b/examples/vite-rsc/package.json new file mode 100644 index 0000000000..983e8014a1 --- /dev/null +++ b/examples/vite-rsc/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-starter", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.3.0" + } +} diff --git a/examples/vite-rsc/public/vite.svg b/examples/vite-rsc/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/examples/vite-rsc/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vite-rsc/src/action.tsx b/examples/vite-rsc/src/action.tsx new file mode 100644 index 0000000000..4fc55d65bd --- /dev/null +++ b/examples/vite-rsc/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/examples/vite-rsc/src/assets/react.svg b/examples/vite-rsc/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/examples/vite-rsc/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vite-rsc/src/client.tsx b/examples/vite-rsc/src/client.tsx new file mode 100644 index 0000000000..29bb5d3671 --- /dev/null +++ b/examples/vite-rsc/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/examples/vite-rsc/src/framework/entry.browser.tsx b/examples/vite-rsc/src/framework/entry.browser.tsx new file mode 100644 index 0000000000..c3a9155e15 --- /dev/null +++ b/examples/vite-rsc/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import { GlobalErrorBoundary } from './error-boundary' +import type { RscPayload } from './entry.rsc' +import { createRscRenderRequest } from './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 + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // 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 renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering 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/examples/vite-rsc/src/framework/entry.rsc.tsx b/examples/vite-rsc/src/framework/entry.rsc.tsx new file mode 100644 index 0000000000..b1bab91cee --- /dev/null +++ b/examples/vite-rsc/src/framework/entry.rsc.tsx @@ -0,0 +1,120 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. +export default async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + 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(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/examples/vite-rsc/src/framework/entry.ssr.tsx b/examples/vite-rsc/src/framework/entry.ssr.tsx new file mode 100644 index 0000000000..7fc5a95643 --- /dev/null +++ b/examples/vite-rsc/src/framework/entry.ssr.tsx @@ -0,0 +1,74 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/examples/vite-rsc/src/framework/error-boundary.tsx b/examples/vite-rsc/src/framework/error-boundary.tsx new file mode 100644 index 0000000000..39d9165104 --- /dev/null +++ b/examples/vite-rsc/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/examples/vite-rsc/src/framework/request.tsx b/examples/vite-rsc/src/framework/request.tsx new file mode 100644 index 0000000000..4c7c666e84 --- /dev/null +++ b/examples/vite-rsc/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/examples/vite-rsc/src/index.css b/examples/vite-rsc/src/index.css new file mode 100644 index 0000000000..f4d2128c01 --- /dev/null +++ b/examples/vite-rsc/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/examples/vite-rsc/src/root.tsx b/examples/vite-rsc/src/root.tsx new file mode 100644 index 0000000000..c6a6497063 --- /dev/null +++ b/examples/vite-rsc/src/root.tsx @@ -0,0 +1,71 @@ +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(props: { url: URL }) { + return ( + + + + + + Vite + RSC + + + + + + ) +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{' '} + + ?__rsc + {' '} + to view RSC stream payload. +
  • +
  • + Visit{' '} + + ?__nojs + {' '} + to test server action without js enabled. +
  • +
+
+ ) +} diff --git a/examples/vite-rsc/tsconfig.json b/examples/vite-rsc/tsconfig.json new file mode 100644 index 0000000000..4c355ed3c8 --- /dev/null +++ b/examples/vite-rsc/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/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts new file mode 100644 index 0000000000..6319f33f6c --- /dev/null +++ b/examples/vite-rsc/vite.config.ts @@ -0,0 +1,72 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + rsc({ + // `entries` option is only a shorthand for specifying each `rollupOptions.input` below + // > entries: { rsc, ssr, client }, + // + // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. + // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. + // > serverHandler: false + }), + + // use any of react plugins https://github.com/vitejs/vite-plugin-react + // to enable client component HMR + react(), + + // use https://github.com/antfu-collective/vite-plugin-inspect + // to understand internal transforms required for RSC. + // import("vite-plugin-inspect").then(m => m.default()), + ], + + // specify entry point for each environment. + // (currently the plugin assumes `rollupOptions.input.index` for some features.) + environments: { + // `rsc` environment loads modules with `react-server` condition. + // this environment is responsible for: + // - RSC stream serialization (React VDOM -> RSC stream) + // - server functions handling + rsc: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.rsc.tsx', + }, + }, + }, + }, + + // `ssr` environment loads modules without `react-server` condition. + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional SSR (React VDOM -> HTML string/stream) + ssr: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.ssr.tsx', + }, + }, + }, + }, + + // client environment is used for hydration and client-side rendering + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional CSR (React VDOM -> Browser DOM tree mount/hydration) + // - refetch and re-render RSC + // - calling server functions + client: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f08cdb76d..850aa71049 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,34 @@ importers: specifier: beta version: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + examples/vite-rsc: + dependencies: + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: latest + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-rsc': + specifier: latest + version: 0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^7.3.0 + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + examples/vite-ssr-html: devDependencies: '@tailwindcss/vite': @@ -3132,6 +3160,17 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-rsc@0.5.10': + resolution: {integrity: sha512-cJxZWtaTM8F7xdAIFqj3F4T7hrhNl8EG6c2SZjkWDWnFCR0y7vagASZ4IoA4N3TAVV8EGecbSEgokBcwDB+FUw==} + peerDependencies: + react: '*' + react-dom: '*' + react-server-dom-webpack: '*' + vite: '*' + peerDependenciesMeta: + react-server-dom-webpack: + optional: true + '@vitejs/plugin-vue@6.0.3': resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4035,6 +4074,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4745,6 +4787,9 @@ packages: is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regexp@3.1.0: resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} engines: {node: '>=12'} @@ -5590,6 +5635,9 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + periscopic@4.0.2: + resolution: {integrity: sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5922,6 +5970,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rsc-html-stream@0.0.7: + resolution: {integrity: sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -6349,6 +6400,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo-stream@3.1.0: + resolution: {integrity: sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6932,6 +6986,9 @@ packages: zhead@2.2.4: resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -9461,6 +9518,18 @@ snapshots: unhead: 1.11.20 vue: 3.5.26(typescript@5.9.3) + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@5.1.2(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -9473,6 +9542,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-rsc@0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + es-module-lexer: 2.0.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + periscopic: 4.0.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + srvx: 0.10.0 + strip-literal: 3.1.0 + turbo-stream: 3.1.0 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 @@ -10376,6 +10459,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -11248,6 +11333,10 @@ snapshots: dependencies: '@types/estree': 1.0.8 + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regexp@3.1.0: {} is-stream@2.0.1: {} @@ -12311,6 +12400,12 @@ snapshots: perfect-debounce@2.0.0: {} + periscopic@4.0.2: + dependencies: + '@types/estree': 1.0.8 + is-reference: 3.0.3 + zimmerframe: 1.1.4 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -12743,6 +12838,8 @@ snapshots: transitivePeerDependencies: - supports-color + rsc-html-stream@0.0.7: {} + run-applescript@7.1.0: {} rxjs@7.8.2: @@ -13178,6 +13275,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + turbo-stream@3.1.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -13469,6 +13568,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitefu@1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitefu@1.1.1(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: vite: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) @@ -13701,6 +13804,8 @@ snapshots: zhead@2.2.4: {} + zimmerframe@1.1.4: {} + zod@3.22.3: {} zod@3.25.76: {} From 3f46f4b23d74e9fb18f133ca0d3db0616439fc8d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:27:18 +0900 Subject: [PATCH 03/13] chore: define __VITE_ENVIRONMENT_RUNNER_IMPORT__ in rsc example --- examples/vite-rsc/package.json | 3 ++- examples/vite-rsc/src/framework/entry.ssr.tsx | 25 +++++++++++++++++++ examples/vite-rsc/vite.config.ts | 13 ++++++++++ pnpm-lock.yaml | 14 +++++++---- src/build/vite/types.ts | 1 + 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/examples/vite-rsc/package.json b/examples/vite-rsc/package.json index 983e8014a1..82a2e7ab03 100644 --- a/examples/vite-rsc/package.json +++ b/examples/vite-rsc/package.json @@ -17,7 +17,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "latest", - "@vitejs/plugin-rsc": "latest", + "@vitejs/plugin-rsc": "https://pkg.pr.new/@vitejs/plugin-rsc@687458d", + "nitro": "latest", "rsc-html-stream": "^0.0.7", "vite": "^7.3.0" } diff --git a/examples/vite-rsc/src/framework/entry.ssr.tsx b/examples/vite-rsc/src/framework/entry.ssr.tsx index 7fc5a95643..6bcec15652 100644 --- a/examples/vite-rsc/src/framework/entry.ssr.tsx +++ b/examples/vite-rsc/src/framework/entry.ssr.tsx @@ -72,3 +72,28 @@ export async function renderHTML( return { stream: responseStream, status } } + +// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ +declare global { + var __nitro_vite_envs__: any; + var __VITE_ENVIRONMENT_RUNNER_IMPORT__: (environmentName: string, id: string) => Promise; +} + +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( + environmentName: string, + id: string, +) { + return await globalThis.__nitro_vite_envs__[environmentName].runner.import(id); +}; + +async function ssrHandler(request: Request) { + const rscEntryModule = await import.meta.viteRsc.loadModule( + "rsc", + "index", + ); + return rscEntryModule.default(request); +} + +export default { + fetch: ssrHandler, +}; diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index 6319f33f6c..4661a2516f 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -1,9 +1,21 @@ import rsc from '@vitejs/plugin-rsc' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' +import { nitro } from "nitro/vite"; export default defineConfig({ plugins: [ + nitro({ + services: { + ssr: { + entry: "./src/framework/entry.ssr.tsx", + }, + rsc: { + entry: "./src/framework/entry.rsc.tsx", + }, + }, + }) as any, + rsc({ // `entries` option is only a shorthand for specifying each `rollupOptions.input` below // > entries: { rsc, ssr, client }, @@ -11,6 +23,7 @@ export default defineConfig({ // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. // > serverHandler: false + serverHandler: false, }), // use any of react plugins https://github.com/vitejs/vite-plugin-react diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 850aa71049..303ee726d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,8 +495,11 @@ importers: specifier: latest version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-rsc': - specifier: latest - version: 0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + specifier: https://pkg.pr.new/@vitejs/plugin-rsc@687458d + version: https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + nitro: + specifier: link:../.. + version: link:../.. rsc-html-stream: specifier: ^0.0.7 version: 0.0.7 @@ -3160,8 +3163,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitejs/plugin-rsc@0.5.10': - resolution: {integrity: sha512-cJxZWtaTM8F7xdAIFqj3F4T7hrhNl8EG6c2SZjkWDWnFCR0y7vagASZ4IoA4N3TAVV8EGecbSEgokBcwDB+FUw==} + '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d': + resolution: {integrity: sha512-0s8D3QrdMOF2cbrtDVq3bbjKUFAW6SL6StTqPVIMlcQQUAg2bCv5prg1B/drYcENrfRPOJVYhPVcDlGCJU27gQ==, tarball: https://pkg.pr.new/@vitejs/plugin-rsc@687458d} + version: 0.5.10 peerDependencies: react: '*' react-dom: '*' @@ -9542,7 +9546,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-rsc@0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: es-module-lexer: 2.0.0 estree-walker: 3.0.3 diff --git a/src/build/vite/types.ts b/src/build/vite/types.ts index 8526b1460a..c10d24051e 100644 --- a/src/build/vite/types.ts +++ b/src/build/vite/types.ts @@ -23,6 +23,7 @@ export interface NitroPluginConfig extends NitroConfig { /** * Additional Vite environment services to register. + * TODO: move to experimental.vite.services */ services?: Record; From 9962eeede5cd7ebd503df395c0d285f575b263be Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:32:06 +0900 Subject: [PATCH 04/13] chore: readme --- examples/vite-rsc/README.md | 41 +++---------------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/examples/vite-rsc/README.md b/examples/vite-rsc/README.md index cd11171061..66ba6d8b00 100644 --- a/examples/vite-rsc/README.md +++ b/examples/vite-rsc/README.md @@ -1,40 +1,5 @@ -# Vite + RSC +# Vite + RSC + Nitro Example -This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc). +Copied from https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) - -```sh -# run dev server -npm run dev - -# build for production and preview -npm run build -npm run preview -``` - -## API usage - -See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. - -- [`vite.config.ts`](./vite.config.ts) - - `@vitejs/plugin-rsc/plugin` -- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) - - `@vitejs/plugin-rsc/rsc` - - `import.meta.viteRsc.loadModule` -- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) - - `@vitejs/plugin-rsc/ssr` - - `import.meta.viteRsc.loadBootstrapScriptContent` - - `rsc-html-stream/server` -- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) - - `@vitejs/plugin-rsc/browser` - - `rsc-html-stream/client` - -## Notes - -- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework. -- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally. - -## Deployment - -See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) +The difference from the original template is to export `default.fetch` handler from `entry.ssr.tsx` instead of `entry.rsc.tsx`. From cc5e79c21bcb2a72633baceab13426670a95b267 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:33:43 +0900 Subject: [PATCH 05/13] chore: lint --- examples/vite-rsc/src/action.tsx | 8 +- examples/vite-rsc/src/client.tsx | 8 +- .../vite-rsc/src/framework/entry.browser.tsx | 125 +++++++++--------- examples/vite-rsc/src/framework/entry.rsc.tsx | 89 +++++++------ examples/vite-rsc/src/framework/entry.ssr.tsx | 72 +++++----- .../vite-rsc/src/framework/error-boundary.tsx | 54 ++++---- examples/vite-rsc/src/framework/request.tsx | 46 +++---- examples/vite-rsc/src/root.tsx | 23 ++-- examples/vite-rsc/vite.config.ts | 14 +- 9 files changed, 223 insertions(+), 216 deletions(-) diff --git a/examples/vite-rsc/src/action.tsx b/examples/vite-rsc/src/action.tsx index 4fc55d65bd..6b5029dcb5 100644 --- a/examples/vite-rsc/src/action.tsx +++ b/examples/vite-rsc/src/action.tsx @@ -1,11 +1,11 @@ -'use server' +"use server"; -let serverCounter = 0 +let serverCounter = 0; export async function getServerCounter() { - return serverCounter + return serverCounter; } export async function updateServerCounter(change: number) { - serverCounter += change + serverCounter += change; } diff --git a/examples/vite-rsc/src/client.tsx b/examples/vite-rsc/src/client.tsx index 29bb5d3671..f857e6355e 100644 --- a/examples/vite-rsc/src/client.tsx +++ b/examples/vite-rsc/src/client.tsx @@ -1,13 +1,13 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; export function ClientCounter() { - const [count, setCount] = React.useState(0) + const [count, setCount] = React.useState(0); return ( - ) + ); } diff --git a/examples/vite-rsc/src/framework/entry.browser.tsx b/examples/vite-rsc/src/framework/entry.browser.tsx index c3a9155e15..cd22a3a813 100644 --- a/examples/vite-rsc/src/framework/entry.browser.tsx +++ b/examples/vite-rsc/src/framework/entry.browser.tsx @@ -4,64 +4,64 @@ import { setServerCallback, createTemporaryReferenceSet, encodeReply, -} from '@vitejs/plugin-rsc/browser' -import React from 'react' -import { createRoot, hydrateRoot } from 'react-dom/client' -import { rscStream } from 'rsc-html-stream/client' -import { GlobalErrorBoundary } from './error-boundary' -import type { RscPayload } from './entry.rsc' -import { createRscRenderRequest } from './request' +} from "@vitejs/plugin-rsc/browser"; +import React from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { rscStream } from "rsc-html-stream/client"; +import { GlobalErrorBoundary } from "./error-boundary"; +import type { RscPayload } from "./entry.rsc"; +import { createRscRenderRequest } from "./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 + let setPayload: (v: RscPayload) => void; // deserialize RSC stream back to React VDOM for CSR const initialPayload = await createFromReadableStream( // initial RSC stream is injected in SSR stream as - rscStream, - ) + rscStream + ); // browser root component to (re-)render RSC payload as state function BrowserRoot() { - const [payload, setPayload_] = React.useState(initialPayload) + const [payload, setPayload_] = React.useState(initialPayload); React.useEffect(() => { - setPayload = (v) => React.startTransition(() => setPayload_(v)) - }, [setPayload_]) + setPayload = (v) => React.startTransition(() => setPayload_(v)); + }, [setPayload_]); // re-fetch/render on client side navigation React.useEffect(() => { - return listenNavigation(() => fetchRscPayload()) - }, []) + return listenNavigation(() => fetchRscPayload()); + }, []); - return payload.root + return payload.root; } // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { - const renderRequest = createRscRenderRequest(window.location.href) - const payload = await createFromFetch(fetch(renderRequest)) - setPayload(payload) + const renderRequest = createRscRenderRequest(globalThis.location.href); + const payload = await createFromFetch(fetch(renderRequest)); + setPayload(payload); } // register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { - const temporaryReferences = createTemporaryReferenceSet() - const renderRequest = createRscRenderRequest(window.location.href, { + const temporaryReferences = createTemporaryReferenceSet(); + const renderRequest = createRscRenderRequest(globalThis.location.href, { id, body: await encodeReply(args, { temporaryReferences }), - }) + }); const payload = await createFromFetch(fetch(renderRequest), { temporaryReferences, - }) - setPayload(payload) - const { ok, data } = payload.returnValue! - if (!ok) throw data - return data - }) + }); + setPayload(payload); + const { ok, data } = payload.returnValue!; + if (!ok) throw data; + return data; + }); // hydration const browserRoot = ( @@ -70,50 +70,50 @@ async function main() { - ) - if ('__NO_HYDRATE' in globalThis) { - createRoot(document).render(browserRoot) + ); + if ("__NO_HYDRATE" in globalThis) { + createRoot(document).render(browserRoot); } else { hydrateRoot(document, browserRoot, { formState: initialPayload.formState, - }) + }); } // implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { - import.meta.hot.on('rsc:update', () => { - fetchRscPayload() - }) + 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 - } + globalThis.addEventListener("popstate", onNavigation); + + const oldPushState = globalThis.history.pushState; + globalThis.history.pushState = function (...args) { + const res = oldPushState.apply(this, args); + onNavigation(); + return res; + }; + + const oldReplaceState = globalThis.history.replaceState; + globalThis.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') + const link = (e.target as Element).closest("a"); if ( link && link instanceof HTMLAnchorElement && link.href && - (!link.target || link.target === '_self') && + (!link.target || link.target === "_self") && link.origin === location.origin && - !link.hasAttribute('download') && + !link.hasAttribute("download") && e.button === 0 && // left clicks only !e.metaKey && // open in new tab (mac) !e.ctrlKey && // open in new tab (windows) @@ -121,18 +121,19 @@ function listenNavigation(onNavigation: () => void) { !e.shiftKey && !e.defaultPrevented ) { - e.preventDefault() - history.pushState(null, '', link.href) + e.preventDefault(); + history.pushState(null, "", link.href); } } - document.addEventListener('click', onClick) + document.addEventListener("click", onClick); return () => { - document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onNavigation) - window.history.pushState = oldPushState - window.history.replaceState = oldReplaceState - } + document.removeEventListener("click", onClick); + globalThis.removeEventListener("popstate", onNavigation); + globalThis.history.pushState = oldPushState; + globalThis.history.replaceState = oldReplaceState; + }; } -main() +// eslint-disable-next-line unicorn/prefer-top-level-await +main(); diff --git a/examples/vite-rsc/src/framework/entry.rsc.tsx b/examples/vite-rsc/src/framework/entry.rsc.tsx index b1bab91cee..b68e705786 100644 --- a/examples/vite-rsc/src/framework/entry.rsc.tsx +++ b/examples/vite-rsc/src/framework/entry.rsc.tsx @@ -5,10 +5,10 @@ import { loadServerAction, decodeAction, decodeFormState, -} from '@vitejs/plugin-rsc/rsc' -import type { ReactFormState } from 'react-dom/client' -import { Root } from '../root.tsx' -import { parseRenderRequest } from './request.tsx' +} from "@vitejs/plugin-rsc/rsc"; +import type { ReactFormState } from "react-dom/client"; +import { Root } from "../root.tsx"; +import { parseRenderRequest } from "./request.tsx"; // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -16,57 +16,58 @@ export type RscPayload = { // this demo renders/serializes/deserizlies entire root html element // but this mechanism can be changed to render/fetch different parts of components // based on your own route conventions. - root: React.ReactNode + root: React.ReactNode; // server action return value of non-progressive enhancement case - returnValue?: { ok: boolean; data: unknown } + returnValue?: { ok: boolean; data: unknown }; // server action form state (e.g. useActionState) of progressive enhancement case - formState?: ReactFormState -} + formState?: ReactFormState; +}; // the plugin by default assumes `rsc` entry having default export of request handler. // however, how server entries are executed can be customized by registering own server handler. export default async function handler(request: Request): Promise { // differentiate RSC, SSR, action, etc. - const renderRequest = parseRenderRequest(request) - request = renderRequest.request + const renderRequest = parseRenderRequest(request); + request = renderRequest.request; // handle server function request - let returnValue: RscPayload['returnValue'] | undefined - let formState: ReactFormState | undefined - let temporaryReferences: unknown | undefined - let actionStatus: number | undefined + let returnValue: RscPayload["returnValue"] | undefined; + let formState: ReactFormState | undefined; + let temporaryReferences: unknown | undefined; + let actionStatus: number | undefined; if (renderRequest.isAction === true) { if (renderRequest.actionId) { // action is called via `ReactClient.setServerCallback`. - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') + 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(renderRequest.actionId) + : await request.text(); + temporaryReferences = createTemporaryReferenceSet(); + const args = await decodeReply(body, { temporaryReferences }); + const action = await loadServerAction(renderRequest.actionId); try { - const data = await action.apply(null, args) - returnValue = { ok: true, data } - } catch (e) { - returnValue = { ok: false, data: e } - actionStatus = 500 + // eslint-disable-next-line prefer-spread + const data = await action.apply(null, args); + returnValue = { ok: true, data }; + } catch (error_) { + returnValue = { ok: false, data: error_ }; + actionStatus = 500; } } else { // otherwise server function is called via `
` // before hydration (e.g. when javascript is disabled). // aka progressive enhancement. - const formData = await request.formData() - const decodedAction = await decodeAction(formData) + const formData = await request.formData(); + const decodedAction = await decodeAction(formData); try { - const result = await decodedAction() - formState = await decodeFormState(result, formData) - } catch (e) { + const result = await decodedAction(); + formState = await decodeFormState(result, formData); + } catch { // there's no single general obvious way to surface this error, // so explicitly return classic 500 response. - return new Response('Internal Server Error: server action failed', { + return new Response("Internal Server Error: server action failed", { status: 500, - }) + }); } } } @@ -79,18 +80,18 @@ export default async function handler(request: Request): Promise { root: , formState, returnValue, - } - const rscOptions = { temporaryReferences } - const rscStream = renderToReadableStream(rscPayload, rscOptions) + }; + const rscOptions = { temporaryReferences }; + const rscStream = renderToReadableStream(rscPayload, rscOptions); // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { status: actionStatus, headers: { - 'content-type': 'text/x-component;charset=utf-8', + "content-type": "text/x-component;charset=utf-8", }, - }) + }); } // Delegate to SSR environment for html rendering. @@ -98,23 +99,23 @@ export default async function handler(request: Request): Promise { // in RSC environment. however this can be customized by implementing own runtime communication // e.g. `@cloudflare/vite-plugin`'s service binding. const ssrEntryModule = await import.meta.viteRsc.loadModule< - typeof import('./entry.ssr.tsx') - >('ssr', 'index') + typeof import("./entry.ssr.tsx") + >("ssr", "index"); const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, // allow quick simulation of javascript disabled browser - debugNojs: renderRequest.url.searchParams.has('__nojs'), - }) + debugNojs: renderRequest.url.searchParams.has("__nojs"), + }); // respond html return new Response(ssrResult.stream, { status: ssrResult.status, headers: { - 'Content-type': 'text/html', + "Content-type": "text/html", }, - }) + }); } if (import.meta.hot) { - import.meta.hot.accept() + import.meta.hot.accept(); } diff --git a/examples/vite-rsc/src/framework/entry.ssr.tsx b/examples/vite-rsc/src/framework/entry.ssr.tsx index 6bcec15652..eee6f2a509 100644 --- a/examples/vite-rsc/src/framework/entry.ssr.tsx +++ b/examples/vite-rsc/src/framework/entry.ssr.tsx @@ -1,37 +1,37 @@ -import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' -import React from 'react' -import type { ReactFormState } from 'react-dom/client' -import { renderToReadableStream } from 'react-dom/server.edge' -import { injectRSCPayload } from 'rsc-html-stream/server' -import type { RscPayload } from './entry.rsc' +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import React from "react"; +import type { ReactFormState } from "react-dom/client"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { injectRSCPayload } from "rsc-html-stream/server"; +import type { RscPayload } from "./entry.rsc"; export async function renderHTML( rscStream: ReadableStream, options: { - formState?: ReactFormState - nonce?: string - debugNojs?: boolean - }, + formState?: ReactFormState; + nonce?: string; + debugNojs?: boolean; + } ): Promise<{ stream: ReadableStream; status?: number }> { // duplicate one RSC stream into two. // - one for SSR (ReactClient.createFromReadableStream below) // - another for browser hydration payload by injecting . - const [rscStream1, rscStream2] = rscStream.tee() + const [rscStream1, rscStream2] = rscStream.tee(); // deserialize RSC stream back to React VDOM - let payload: Promise | undefined + let payload: Promise | undefined; function SsrRoot() { // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work - payload ??= createFromReadableStream(rscStream1) - return React.use(payload).root + payload ??= createFromReadableStream(rscStream1); + return React.use(payload).root; } // render html (traditional SSR) const bootstrapScriptContent = - await import.meta.viteRsc.loadBootstrapScriptContent('index') - let htmlStream: ReadableStream - let status: number | undefined + await import.meta.viteRsc.loadBootstrapScriptContent("index"); + let htmlStream: ReadableStream; + let status: number | undefined; try { htmlStream = await renderToReadableStream(, { bootstrapScriptContent: options?.debugNojs @@ -39,11 +39,11 @@ export async function renderHTML( : bootstrapScriptContent, nonce: options?.nonce, formState: options?.formState, - }) - } catch (e) { + }); + } catch { // fallback to render an empty shell and run pure CSR on browser, // which can replay server component error and trigger error boundary. - status = 500 + status = 500; htmlStream = await renderToReadableStream( @@ -53,44 +53,48 @@ export async function renderHTML( { bootstrapScriptContent: `self.__NO_HYDRATE=1;` + - (options?.debugNojs ? '' : bootstrapScriptContent), + (options?.debugNojs ? "" : bootstrapScriptContent), nonce: options?.nonce, - }, - ) + } + ); } - let responseStream: ReadableStream = htmlStream + let responseStream: ReadableStream = htmlStream; if (!options?.debugNojs) { // initial RSC stream is injected in HTML stream as // using utility made by devongovett https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, - }), - ) + }) + ); } - return { stream: responseStream, status } + return { stream: responseStream, status }; } // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ declare global { var __nitro_vite_envs__: any; - var __VITE_ENVIRONMENT_RUNNER_IMPORT__: (environmentName: string, id: string) => Promise; + var __VITE_ENVIRONMENT_RUNNER_IMPORT__: ( + environmentName: string, + id: string + ) => Promise; } globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( environmentName: string, - id: string, + id: string ) { - return await globalThis.__nitro_vite_envs__[environmentName].runner.import(id); + return await globalThis.__nitro_vite_envs__[environmentName].runner.import( + id + ); }; async function ssrHandler(request: Request) { - const rscEntryModule = await import.meta.viteRsc.loadModule( - "rsc", - "index", - ); + const rscEntryModule = await import.meta.viteRsc.loadModule< + typeof import("./entry.rsc") + >("rsc", "index"); return rscEntryModule.default(request); } diff --git a/examples/vite-rsc/src/framework/error-boundary.tsx b/examples/vite-rsc/src/framework/error-boundary.tsx index 39d9165104..535dab6ac7 100644 --- a/examples/vite-rsc/src/framework/error-boundary.tsx +++ b/examples/vite-rsc/src/framework/error-boundary.tsx @@ -1,6 +1,6 @@ -'use client' +"use client"; -import React from 'react' +import React from "react"; // Minimal ErrorBoundary example to handle errors globally on browser export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { @@ -8,34 +8,34 @@ export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { {props.children} - ) + ); } // https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx // https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary class ErrorBoundary extends React.Component<{ - children?: React.ReactNode + children?: React.ReactNode; errorComponent: React.FC<{ - error: Error - reset: () => void - }> + error: Error; + reset: () => void; + }>; }> { - state: { error?: Error } = {} + state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { - return { error } + return { error }; } reset = () => { - this.setState({ error: null }) - } + this.setState({ error: null }); + }; render() { - const error = this.state.error + const error = this.state.error; if (error) { - return + return ; } - return this.props.children + return this.props.children; } } @@ -49,33 +49,33 @@ function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) {

Caught an unexpected error

-          Error:{' '}
-          {import.meta.env.DEV && 'message' in props.error
+          Error:{" "}
+          {import.meta.env.DEV && "message" in props.error
             ? props.error.message
-            : '(Unknown)'}
+            : "(Unknown)"}
         
- ) + ); } diff --git a/examples/vite-rsc/src/framework/request.tsx b/examples/vite-rsc/src/framework/request.tsx index 4c7c666e84..d68a29547c 100644 --- a/examples/vite-rsc/src/framework/request.tsx +++ b/examples/vite-rsc/src/framework/request.tsx @@ -1,44 +1,44 @@ // Framework conventions (arbitrary choices for this demo): // - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests // - Use `x-rsc-action` header to pass server action ID -const URL_POSTFIX = '_.rsc' -const HEADER_ACTION_ID = 'x-rsc-action' +const URL_POSTFIX = "_.rsc"; +const HEADER_ACTION_ID = "x-rsc-action"; // Parsed request information used to route between RSC/SSR rendering and action handling. // Created by parseRenderRequest() from incoming HTTP requests. type RenderRequest = { - isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) - isAction: boolean // true if this is a server action call (POST request) - actionId?: string // server action ID from x-rsc-action header - request: Request // normalized Request with _.rsc suffix removed from URL - url: URL // normalized URL with _.rsc suffix removed -} + isRsc: boolean; // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean; // true if this is a server action call (POST request) + actionId?: string; // server action ID from x-rsc-action header + request: Request; // normalized Request with _.rsc suffix removed from URL + url: URL; // normalized URL with _.rsc suffix removed +}; export function createRscRenderRequest( urlString: string, - action?: { id: string; body: BodyInit }, + action?: { id: string; body: BodyInit } ): Request { - const url = new URL(urlString) - url.pathname += URL_POSTFIX - const headers = new Headers() + const url = new URL(urlString); + url.pathname += URL_POSTFIX; + const headers = new Headers(); if (action) { - headers.set(HEADER_ACTION_ID, action.id) + headers.set(HEADER_ACTION_ID, action.id); } return new Request(url.toString(), { - method: action ? 'POST' : 'GET', + method: action ? "POST" : "GET", headers, body: action?.body, - }) + }); } export function parseRenderRequest(request: Request): RenderRequest { - const url = new URL(request.url) - const isAction = request.method === 'POST' + const url = new URL(request.url); + const isAction = request.method === "POST"; if (url.pathname.endsWith(URL_POSTFIX)) { - url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) - const actionId = request.headers.get(HEADER_ACTION_ID) || undefined - if (request.method === 'POST' && !actionId) { - throw new Error('Missing action id header for RSC action request') + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length); + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined; + if (request.method === "POST" && !actionId) { + throw new Error("Missing action id header for RSC action request"); } return { isRsc: true, @@ -46,13 +46,13 @@ export function parseRenderRequest(request: Request): RenderRequest { actionId, request: new Request(url, request), url, - } + }; } else { return { isRsc: false, isAction, request, url, - } + }; } } diff --git a/examples/vite-rsc/src/root.tsx b/examples/vite-rsc/src/root.tsx index c6a6497063..1940464b13 100644 --- a/examples/vite-rsc/src/root.tsx +++ b/examples/vite-rsc/src/root.tsx @@ -1,13 +1,14 @@ -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' +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(props: { url: URL }) { return ( + {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */} @@ -17,7 +18,7 @@ export function Root(props: { url: URL }) { - ) + ); } function App(props: { url: URL }) { @@ -52,20 +53,20 @@ function App(props: { url: URL }) { Edit src/root.tsx to test server HMR.
  • - Visit{' '} + Visit{" "} ?__rsc - {' '} + {" "} to view RSC stream payload.
  • - Visit{' '} + Visit{" "} ?__nojs - {' '} + {" "} to test server action without js enabled.
  • - ) + ); } diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index 4661a2516f..b07fd26fcf 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -1,6 +1,6 @@ -import rsc from '@vitejs/plugin-rsc' -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import rsc from "@vitejs/plugin-rsc"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; import { nitro } from "nitro/vite"; export default defineConfig({ @@ -46,7 +46,7 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: './src/framework/entry.rsc.tsx', + index: "./src/framework/entry.rsc.tsx", }, }, }, @@ -60,7 +60,7 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: './src/framework/entry.ssr.tsx', + index: "./src/framework/entry.ssr.tsx", }, }, }, @@ -76,10 +76,10 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: './src/framework/entry.browser.tsx', + index: "./src/framework/entry.browser.tsx", }, }, }, }, }, -}) +}); From 179fb0031abaca2477d8eba740e2df2b05428554 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:36:36 +0900 Subject: [PATCH 06/13] feat: define __VITE_ENVIRONMENT_RUNNER_IMPORT__ ootb --- examples/vite-rsc/src/framework/entry.ssr.tsx | 32 ++++--------------- src/runtime/internal/vite/node-runner.mjs | 13 ++++++++ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/examples/vite-rsc/src/framework/entry.ssr.tsx b/examples/vite-rsc/src/framework/entry.ssr.tsx index eee6f2a509..3f93a254f5 100644 --- a/examples/vite-rsc/src/framework/entry.ssr.tsx +++ b/examples/vite-rsc/src/framework/entry.ssr.tsx @@ -73,31 +73,11 @@ export async function renderHTML( return { stream: responseStream, status }; } -// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ -declare global { - var __nitro_vite_envs__: any; - var __VITE_ENVIRONMENT_RUNNER_IMPORT__: ( - environmentName: string, - id: string - ) => Promise; -} - -globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( - environmentName: string, - id: string -) { - return await globalThis.__nitro_vite_envs__[environmentName].runner.import( - id - ); -}; - -async function ssrHandler(request: Request) { - const rscEntryModule = await import.meta.viteRsc.loadModule< - typeof import("./entry.rsc") - >("rsc", "index"); - return rscEntryModule.default(request); -} - export default { - fetch: ssrHandler, + fetch: async (request: Request) => { + const rscEntryModule = await import.meta.viteRsc.loadModule< + typeof import("./entry.rsc") + >("rsc", "index"); + return rscEntryModule.default(request); + }, }; diff --git a/src/runtime/internal/vite/node-runner.mjs b/src/runtime/internal/vite/node-runner.mjs index 964e76e047..0bd148f04c 100644 --- a/src/runtime/internal/vite/node-runner.mjs +++ b/src/runtime/internal/vite/node-runner.mjs @@ -10,6 +10,19 @@ const envs = (globalThis.__nitro_vite_envs__ ??= { ssr: undefined, }); +// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support +// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( + environmentName, + id +) { + const env = envs[environmentName]; + if (!env) { + throw new Error(`Vite environment "${environmentName}" is not registered`); + } + return env.runner.import(id); +}; + class EnvRunner { constructor({ name, entry }) { this.name = name; From 6092cb1540235bc6c62f8f8e84040a46af70fd6a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:40:46 +0900 Subject: [PATCH 07/13] chore(vite): move services config to experimental.vite.services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/vite-rsc/vite.config.ts | 16 ++++++++++------ src/build/vite/plugin.ts | 2 +- src/build/vite/types.ts | 13 ++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index b07fd26fcf..7c3cbbc813 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -6,12 +6,16 @@ import { nitro } from "nitro/vite"; export default defineConfig({ plugins: [ nitro({ - services: { - ssr: { - entry: "./src/framework/entry.ssr.tsx", - }, - rsc: { - entry: "./src/framework/entry.rsc.tsx", + experimental: { + vite: { + services: { + ssr: { + entry: "./src/framework/entry.ssr.tsx", + }, + rsc: { + entry: "./src/framework/entry.rsc.tsx", + }, + }, }, }, }) as any, diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index ac9d341ad8..ed66378280 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -285,7 +285,7 @@ function nitroService(ctx: NitroPluginContext): VitePlugin { function createContext(pluginConfig: NitroPluginConfig): NitroPluginContext { return { pluginConfig, - services: { ...pluginConfig.services }, + services: { ...pluginConfig.experimental?.vite?.services }, _entryPoints: {}, }; } diff --git a/src/build/vite/types.ts b/src/build/vite/types.ts index c10d24051e..ed64f604ab 100644 --- a/src/build/vite/types.ts +++ b/src/build/vite/types.ts @@ -21,12 +21,6 @@ export interface NitroPluginConfig extends NitroConfig { */ _nitro?: Nitro; - /** - * Additional Vite environment services to register. - * TODO: move to experimental.vite.services - */ - services?: Record; - experimental?: NitroConfig["experimental"] & { vite: { /** @@ -40,7 +34,12 @@ export interface NitroPluginConfig extends NitroConfig { * * @default true */ - serverReload: boolean; + serverReload?: boolean; + + /** + * Additional Vite environment services to register. + */ + services?: Record; }; }; } From 91e464819cdcdc9e22547ae8b9afed18e5f2aeed Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:42:12 +0900 Subject: [PATCH 08/13] chore: cleanup --- examples/vite-rsc/vite.config.ts | 50 -------------------------------- 1 file changed, 50 deletions(-) diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index 7c3cbbc813..84ac48e2d4 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -19,63 +19,13 @@ export default defineConfig({ }, }, }) as any, - rsc({ - // `entries` option is only a shorthand for specifying each `rollupOptions.input` below - // > entries: { rsc, ssr, client }, - // - // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. - // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. - // > serverHandler: false serverHandler: false, }), - - // use any of react plugins https://github.com/vitejs/vite-plugin-react - // to enable client component HMR react(), - - // use https://github.com/antfu-collective/vite-plugin-inspect - // to understand internal transforms required for RSC. - // import("vite-plugin-inspect").then(m => m.default()), ], - // specify entry point for each environment. - // (currently the plugin assumes `rollupOptions.input.index` for some features.) environments: { - // `rsc` environment loads modules with `react-server` condition. - // this environment is responsible for: - // - RSC stream serialization (React VDOM -> RSC stream) - // - server functions handling - rsc: { - build: { - rollupOptions: { - input: { - index: "./src/framework/entry.rsc.tsx", - }, - }, - }, - }, - - // `ssr` environment loads modules without `react-server` condition. - // this environment is responsible for: - // - RSC stream deserialization (RSC stream -> React VDOM) - // - traditional SSR (React VDOM -> HTML string/stream) - ssr: { - build: { - rollupOptions: { - input: { - index: "./src/framework/entry.ssr.tsx", - }, - }, - }, - }, - - // client environment is used for hydration and client-side rendering - // this environment is responsible for: - // - RSC stream deserialization (RSC stream -> React VDOM) - // - traditional CSR (React VDOM -> Browser DOM tree mount/hydration) - // - refetch and re-render RSC - // - calling server functions client: { build: { rollupOptions: { From ee0d6f6c6ef4bc8fb71c732821a4a40589843f48 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:43:43 +0900 Subject: [PATCH 09/13] chore: use vite beta --- examples/vite-rsc/package.json | 2 +- examples/vite-rsc/vite.config.ts | 2 +- pnpm-lock.yaml | 30 +++++++----------------------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/examples/vite-rsc/package.json b/examples/vite-rsc/package.json index 82a2e7ab03..5cfb84fb05 100644 --- a/examples/vite-rsc/package.json +++ b/examples/vite-rsc/package.json @@ -20,6 +20,6 @@ "@vitejs/plugin-rsc": "https://pkg.pr.new/@vitejs/plugin-rsc@687458d", "nitro": "latest", "rsc-html-stream": "^0.0.7", - "vite": "^7.3.0" + "vite": "beta" } } diff --git a/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index 84ac48e2d4..a968520f73 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ }, }, }, - }) as any, + }), rsc({ serverHandler: false, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303ee726d7..50794d04d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,10 +493,10 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: latest - version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.2(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-rsc': specifier: https://pkg.pr.new/@vitejs/plugin-rsc@687458d - version: https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) nitro: specifier: link:../.. version: link:../.. @@ -504,8 +504,8 @@ importers: specifier: ^0.0.7 version: 0.0.7 vite: - specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + specifier: beta + version: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) examples/vite-ssr-html: devDependencies: @@ -9522,18 +9522,6 @@ snapshots: unhead: 1.11.20 vue: 3.5.26(typescript@5.9.3) - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.53 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@5.1.2(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -9546,7 +9534,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-rsc@https://pkg.pr.new/@vitejs/plugin-rsc@687458d(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: es-module-lexer: 2.0.0 estree-walker: 3.0.3 @@ -9557,8 +9545,8 @@ snapshots: srvx: 0.10.0 strip-literal: 3.1.0 turbo-stream: 3.1.0 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + vite: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': dependencies: @@ -13572,10 +13560,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): - optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - vitefu@1.1.1(vite@8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: vite: 8.0.0-beta.5(@types/node@25.0.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) From 2f4c575a158fc6a4262a03bee7bf3abd5ae68fc5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 5 Jan 2026 18:56:22 +0900 Subject: [PATCH 10/13] chore: exclude vite-rsc example from root type checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example has its own tsconfig with Bundler moduleResolution and @vitejs/plugin-rsc/types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 86db00fea8..0348a13229 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ }, "exclude": [ "examples/import-alias/**", + "examples/vite-rsc/**", "test/fixture/server/routes/jsx.tsx", "examples/vite-ssr-solid/src/entry-server.tsx", "examples/vite-ssr-solid/src/entry-client.tsx" From 3a5dfb56c3408012ee4cb78e932ee644645ee5dc Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 5 Jan 2026 15:58:07 +0100 Subject: [PATCH 11/13] reorder runner init --- src/runtime/internal/vite/node-runner.mjs | 29 +++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/runtime/internal/vite/node-runner.mjs b/src/runtime/internal/vite/node-runner.mjs index 0bd148f04c..39391dae51 100644 --- a/src/runtime/internal/vite/node-runner.mjs +++ b/src/runtime/internal/vite/node-runner.mjs @@ -10,19 +10,6 @@ const envs = (globalThis.__nitro_vite_envs__ ??= { ssr: undefined, }); -// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support -// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ -globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( - environmentName, - id -) { - const env = envs[environmentName]; - if (!env) { - throw new Error(`Vite environment "${environmentName}" is not registered`); - } - return env.runner.import(id); -}; - class EnvRunner { constructor({ name, entry }) { this.name = name; @@ -168,6 +155,22 @@ parentPort.on("message", (payload) => { process.on("unhandledRejection", (error) => console.error(error)); process.on("uncaughtException", (error) => console.error(error)); +// ----- RSC Support ----- + +// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support +// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ + +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function ( + environmentName, + id +) { + const env = envs[environmentName]; + if (!env) { + throw new Error(`Vite environment "${environmentName}" is not registered`); + } + return env.runner.import(id); +}; + // ----- Server ----- async function reload() { From 9070d2b02208b859ca0903cf3ebfe1fbefdbd198 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 5 Jan 2026 16:05:44 +0100 Subject: [PATCH 12/13] small updates on example --- examples/vite-rsc/{src => app}/action.tsx | 0 examples/vite-rsc/app/assets/nitro.svg | 42 +++++++++++++++++++ .../vite-rsc/{src => app}/assets/react.svg | 0 .../vite-rsc/{public => app/assets}/vite.svg | 0 examples/vite-rsc/{src => app}/client.tsx | 0 .../{src => app}/framework/entry.browser.tsx | 20 ++++----- .../{src => app}/framework/entry.rsc.tsx | 37 +++++++++------- .../{src => app}/framework/entry.ssr.tsx | 40 +++++++++--------- .../{src => app}/framework/error-boundary.tsx | 4 +- .../{src => app}/framework/request.tsx | 0 examples/vite-rsc/{src => app}/index.css | 0 examples/vite-rsc/{src => app}/root.tsx | 15 ++++--- examples/vite-rsc/tsconfig.json | 12 +----- examples/vite-rsc/vite.config.ts | 21 ++++------ 14 files changed, 114 insertions(+), 77 deletions(-) rename examples/vite-rsc/{src => app}/action.tsx (100%) create mode 100644 examples/vite-rsc/app/assets/nitro.svg rename examples/vite-rsc/{src => app}/assets/react.svg (100%) rename examples/vite-rsc/{public => app/assets}/vite.svg (100%) rename examples/vite-rsc/{src => app}/client.tsx (100%) rename examples/vite-rsc/{src => app}/framework/entry.browser.tsx (87%) rename examples/vite-rsc/{src => app}/framework/entry.rsc.tsx (80%) rename examples/vite-rsc/{src => app}/framework/entry.ssr.tsx (83%) rename examples/vite-rsc/{src => app}/framework/error-boundary.tsx (97%) rename examples/vite-rsc/{src => app}/framework/request.tsx (100%) rename examples/vite-rsc/{src => app}/index.css (100%) rename examples/vite-rsc/{src => app}/root.tsx (85%) diff --git a/examples/vite-rsc/src/action.tsx b/examples/vite-rsc/app/action.tsx similarity index 100% rename from examples/vite-rsc/src/action.tsx rename to examples/vite-rsc/app/action.tsx diff --git a/examples/vite-rsc/app/assets/nitro.svg b/examples/vite-rsc/app/assets/nitro.svg new file mode 100644 index 0000000000..d6450f9467 --- /dev/null +++ b/examples/vite-rsc/app/assets/nitro.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/vite-rsc/src/assets/react.svg b/examples/vite-rsc/app/assets/react.svg similarity index 100% rename from examples/vite-rsc/src/assets/react.svg rename to examples/vite-rsc/app/assets/react.svg diff --git a/examples/vite-rsc/public/vite.svg b/examples/vite-rsc/app/assets/vite.svg similarity index 100% rename from examples/vite-rsc/public/vite.svg rename to examples/vite-rsc/app/assets/vite.svg diff --git a/examples/vite-rsc/src/client.tsx b/examples/vite-rsc/app/client.tsx similarity index 100% rename from examples/vite-rsc/src/client.tsx rename to examples/vite-rsc/app/client.tsx diff --git a/examples/vite-rsc/src/framework/entry.browser.tsx b/examples/vite-rsc/app/framework/entry.browser.tsx similarity index 87% rename from examples/vite-rsc/src/framework/entry.browser.tsx rename to examples/vite-rsc/app/framework/entry.browser.tsx index cd22a3a813..cce683560e 100644 --- a/examples/vite-rsc/src/framework/entry.browser.tsx +++ b/examples/vite-rsc/app/framework/entry.browser.tsx @@ -13,17 +13,17 @@ import type { RscPayload } from "./entry.rsc"; import { createRscRenderRequest } from "./request"; async function main() { - // stash `setPayload` function to trigger re-rendering + // Stash `setPayload` function to trigger re-rendering // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) let setPayload: (v: RscPayload) => void; - // deserialize RSC stream back to React VDOM for CSR + // Deserialize RSC stream back to React VDOM for CSR const initialPayload = await createFromReadableStream( - // initial RSC stream is injected in SSR stream as + // Initial RSC stream is injected in SSR stream as rscStream ); - // browser root component to (re-)render RSC payload as state + // Browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload); @@ -31,7 +31,7 @@ async function main() { setPayload = (v) => React.startTransition(() => setPayload_(v)); }, [setPayload_]); - // re-fetch/render on client side navigation + // Re-fetch/render on client side navigation React.useEffect(() => { return listenNavigation(() => fetchRscPayload()); }, []); @@ -39,14 +39,14 @@ async function main() { return payload.root; } - // re-fetch RSC and trigger re-rendering + // Re-fetch RSC and trigger re-rendering async function fetchRscPayload() { const renderRequest = createRscRenderRequest(globalThis.location.href); const payload = await createFromFetch(fetch(renderRequest)); setPayload(payload); } - // register a handler which will be internally called by React + // Register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet(); @@ -63,7 +63,7 @@ async function main() { return data; }); - // hydration + // Hydration const browserRoot = ( @@ -79,7 +79,7 @@ async function main() { }); } - // implement server HMR by triggering re-fetch/render of RSC upon server code change + // Implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { import.meta.hot.on("rsc:update", () => { fetchRscPayload(); @@ -87,7 +87,7 @@ async function main() { } } -// a little helper to setup events interception for client side navigation +// A little helper to setup events interception for client side navigation function listenNavigation(onNavigation: () => void) { globalThis.addEventListener("popstate", onNavigation); diff --git a/examples/vite-rsc/src/framework/entry.rsc.tsx b/examples/vite-rsc/app/framework/entry.rsc.tsx similarity index 80% rename from examples/vite-rsc/src/framework/entry.rsc.tsx rename to examples/vite-rsc/app/framework/entry.rsc.tsx index b68e705786..dfe6f57fe7 100644 --- a/examples/vite-rsc/src/framework/entry.rsc.tsx +++ b/examples/vite-rsc/app/framework/entry.rsc.tsx @@ -13,31 +13,34 @@ import { parseRenderRequest } from "./request.tsx"; // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. export type RscPayload = { - // this demo renders/serializes/deserizlies entire root html element + // this demo renders/serializes/deserializes entire root html element // but this mechanism can be changed to render/fetch different parts of components // based on your own route conventions. root: React.ReactNode; - // server action return value of non-progressive enhancement case + + // Server action return value of non-progressive enhancement case returnValue?: { ok: boolean; data: unknown }; - // server action form state (e.g. useActionState) of progressive enhancement case + + // Server action form state (e.g. useActionState) of progressive enhancement case formState?: ReactFormState; }; -// the plugin by default assumes `rsc` entry having default export of request handler. +// The plugin by default assumes `rsc` entry having default export of request handler. // however, how server entries are executed can be customized by registering own server handler. export default async function handler(request: Request): Promise { - // differentiate RSC, SSR, action, etc. + // Differentiate RSC, SSR, action, etc. const renderRequest = parseRenderRequest(request); request = renderRequest.request; - // handle server function request + // Handle server function request let returnValue: RscPayload["returnValue"] | undefined; let formState: ReactFormState | undefined; let temporaryReferences: unknown | undefined; let actionStatus: number | undefined; + if (renderRequest.isAction === true) { if (renderRequest.actionId) { - // action is called via `ReactClient.setServerCallback`. + // Action is called via `ReactClient.setServerCallback`. const contentType = request.headers.get("content-type"); const body = contentType?.startsWith("multipart/form-data") ? await request.formData() @@ -54,8 +57,8 @@ export default async function handler(request: Request): Promise { actionStatus = 500; } } else { - // otherwise server function is called via `` - // before hydration (e.g. when javascript is disabled). + // Otherwise server function is called via `` + // before hydration (e.g. when JavaScript is disabled). // aka progressive enhancement. const formData = await request.formData(); const decodedAction = await decodeAction(formData); @@ -72,8 +75,8 @@ export default async function handler(request: Request): Promise { } } - // serialization from React VDOM tree to RSC stream. - // we render RSC stream after handling server function request + // Serialization from React VDOM tree to RSC stream. + // We render RSC stream after handling server function request // so that new render reflects updated state from server function call // to achieve single round trip to mutate and fetch from server. const rscPayload: RscPayload = { @@ -81,6 +84,7 @@ export default async function handler(request: Request): Promise { formState, returnValue, }; + const rscOptions = { temporaryReferences }; const rscStream = renderToReadableStream(rscPayload, rscOptions); @@ -94,24 +98,25 @@ export default async function handler(request: Request): Promise { }); } - // Delegate to SSR environment for html rendering. + // Delegate to SSR environment for HTML rendering. // The plugin provides `loadModule` helper to allow loading SSR environment entry module // in RSC environment. however this can be customized by implementing own runtime communication // e.g. `@cloudflare/vite-plugin`'s service binding. const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import("./entry.ssr.tsx") >("ssr", "index"); + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, - // allow quick simulation of javascript disabled browser - debugNojs: renderRequest.url.searchParams.has("__nojs"), + // Allow quick simulation of JavaScript disabled browser + debugNoJS: renderRequest.url.searchParams.has("__nojs"), }); - // respond html + // Respond HTML return new Response(ssrResult.stream, { status: ssrResult.status, headers: { - "Content-type": "text/html", + "Content-Type": "text/html", }, }); } diff --git a/examples/vite-rsc/src/framework/entry.ssr.tsx b/examples/vite-rsc/app/framework/entry.ssr.tsx similarity index 83% rename from examples/vite-rsc/src/framework/entry.ssr.tsx rename to examples/vite-rsc/app/framework/entry.ssr.tsx index 3f93a254f5..b5fb08a555 100644 --- a/examples/vite-rsc/src/framework/entry.ssr.tsx +++ b/examples/vite-rsc/app/framework/entry.ssr.tsx @@ -5,36 +5,47 @@ import { renderToReadableStream } from "react-dom/server.edge"; import { injectRSCPayload } from "rsc-html-stream/server"; import type { RscPayload } from "./entry.rsc"; +export default { + fetch: async (request: Request) => { + const rscEntryModule = await import.meta.viteRsc.loadModule< + typeof import("./entry.rsc") + >("rsc", "index"); + return rscEntryModule.default(request); + }, +}; + export async function renderHTML( rscStream: ReadableStream, options: { formState?: ReactFormState; nonce?: string; - debugNojs?: boolean; + debugNoJS?: boolean; } ): Promise<{ stream: ReadableStream; status?: number }> { - // duplicate one RSC stream into two. + // Duplicate one RSC stream into two. // - one for SSR (ReactClient.createFromReadableStream below) // - another for browser hydration payload by injecting . const [rscStream1, rscStream2] = rscStream.tee(); - // deserialize RSC stream back to React VDOM + // Deserialize RSC stream back to React VDOM let payload: Promise | undefined; function SsrRoot() { - // deserialization needs to be kicked off inside ReactDOMServer context - // for ReactDomServer preinit/preloading to work + // Deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDOMServer preinit/preloading to work payload ??= createFromReadableStream(rscStream1); return React.use(payload).root; } - // render html (traditional SSR) + // Render HTML (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); + let htmlStream: ReadableStream; let status: number | undefined; + try { htmlStream = await renderToReadableStream(, { - bootstrapScriptContent: options?.debugNojs + bootstrapScriptContent: options?.debugNoJS ? undefined : bootstrapScriptContent, nonce: options?.nonce, @@ -53,15 +64,15 @@ export async function renderHTML( { bootstrapScriptContent: `self.__NO_HYDRATE=1;` + - (options?.debugNojs ? "" : bootstrapScriptContent), + (options?.debugNoJS ? "" : bootstrapScriptContent), nonce: options?.nonce, } ); } let responseStream: ReadableStream = htmlStream; - if (!options?.debugNojs) { - // initial RSC stream is injected in HTML stream as + if (!options?.debugNoJS) { + // Initial RSC stream is injected in HTML stream as // using utility made by devongovett https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { @@ -72,12 +83,3 @@ export async function renderHTML( return { stream: responseStream, status }; } - -export default { - fetch: async (request: Request) => { - const rscEntryModule = await import.meta.viteRsc.loadModule< - typeof import("./entry.rsc") - >("rsc", "index"); - return rscEntryModule.default(request); - }, -}; diff --git a/examples/vite-rsc/src/framework/error-boundary.tsx b/examples/vite-rsc/app/framework/error-boundary.tsx similarity index 97% rename from examples/vite-rsc/src/framework/error-boundary.tsx rename to examples/vite-rsc/app/framework/error-boundary.tsx index 535dab6ac7..674bc41d6f 100644 --- a/examples/vite-rsc/src/framework/error-boundary.tsx +++ b/examples/vite-rsc/app/framework/error-boundary.tsx @@ -20,7 +20,7 @@ class ErrorBoundary extends React.Component<{ reset: () => void; }>; }> { - state: { error?: Error } = {}; + override state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { return { error }; @@ -30,7 +30,7 @@ class ErrorBoundary extends React.Component<{ this.setState({ error: null }); }; - render() { + override render() { const error = this.state.error; if (error) { return ; diff --git a/examples/vite-rsc/src/framework/request.tsx b/examples/vite-rsc/app/framework/request.tsx similarity index 100% rename from examples/vite-rsc/src/framework/request.tsx rename to examples/vite-rsc/app/framework/request.tsx diff --git a/examples/vite-rsc/src/index.css b/examples/vite-rsc/app/index.css similarity index 100% rename from examples/vite-rsc/src/index.css rename to examples/vite-rsc/app/index.css diff --git a/examples/vite-rsc/src/root.tsx b/examples/vite-rsc/app/root.tsx similarity index 85% rename from examples/vite-rsc/src/root.tsx rename to examples/vite-rsc/app/root.tsx index 1940464b13..e3da1d5408 100644 --- a/examples/vite-rsc/src/root.tsx +++ b/examples/vite-rsc/app/root.tsx @@ -1,7 +1,8 @@ import "./index.css"; // css import is automatically injected in exported server components -import viteLogo from "/vite.svg"; +import viteLogo from "./assets/vite.svg"; import { getServerCounter, updateServerCounter } from "./action.tsx"; import reactLogo from "./assets/react.svg"; +import nitroLogo from "./assets/nitro.svg"; import { ClientCounter } from "./client.tsx"; export function Root(props: { url: URL }) { @@ -12,7 +13,7 @@ export function Root(props: { url: URL }) { - Vite + RSC + Nitro + Vite + RSC @@ -34,8 +35,12 @@ function App(props: { url: URL }) { > React logo + + + Nitro logo + -

    Vite + RSC

    +

    Vite + RSC + Nitro

    @@ -52,13 +57,13 @@ function App(props: { url: URL }) {
  • Edit src/root.tsx to test server HMR.
  • -
  • + {/*
  • Visit{" "} ?__rsc {" "} to view RSC stream payload. -
  • + */}
  • Visit{" "} diff --git a/examples/vite-rsc/tsconfig.json b/examples/vite-rsc/tsconfig.json index 4c355ed3c8..a7b38dc3ca 100644 --- a/examples/vite-rsc/tsconfig.json +++ b/examples/vite-rsc/tsconfig.json @@ -1,16 +1,6 @@ { + "extends": "nitro/tsconfig", "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/examples/vite-rsc/vite.config.ts b/examples/vite-rsc/vite.config.ts index a968520f73..85ad2922b4 100644 --- a/examples/vite-rsc/vite.config.ts +++ b/examples/vite-rsc/vite.config.ts @@ -1,27 +1,22 @@ -import rsc from "@vitejs/plugin-rsc"; -import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { nitro } from "nitro/vite"; +import rsc from "@vitejs/plugin-rsc"; +import react from "@vitejs/plugin-react"; + export default defineConfig({ plugins: [ nitro({ experimental: { vite: { services: { - ssr: { - entry: "./src/framework/entry.ssr.tsx", - }, - rsc: { - entry: "./src/framework/entry.rsc.tsx", - }, + ssr: { entry: "./app/framework/entry.ssr.tsx" }, + rsc: { entry: "./app/framework/entry.rsc.tsx" }, }, }, }, }), - rsc({ - serverHandler: false, - }), + rsc({ serverHandler: false }), react(), ], @@ -29,9 +24,7 @@ export default defineConfig({ client: { build: { rollupOptions: { - input: { - index: "./src/framework/entry.browser.tsx", - }, + input: { index: "./app/framework/entry.browser.tsx" }, }, }, }, From 5d11db503ca774b4993bf060f3ec5ff6e37a8851 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 5 Jan 2026 16:19:03 +0100 Subject: [PATCH 13/13] skip rsc test with rollup --- test/examples.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/examples.test.ts b/test/examples.test.ts index 6d5234f6a9..9e5b484437 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -8,15 +8,17 @@ import type { ViteDevServer } from "vite"; const examplesDir = fileURLToPath(new URL("../examples", import.meta.url)); -const { createServer, createBuilder } = (await import( +const { createServer, createBuilder, rolldownVersion } = (await import( process.env.NITRO_VITE_PKG || "vite" )) as typeof import("vite"); +const isRolldown = !!rolldownVersion; + const skip = new Set(["websocket"]); const skipDev = new Set(["auto-imports", "cached-handler"]); -const skipProd = new Set(); +const skipProd = new Set(isRolldown ? [] : ["vite-rsc"]); for (const example of await readdir(examplesDir)) { if (example.startsWith("_")) continue;