diff --git a/packages/plugin-rsc/e2e/browser-mode.test.ts b/packages/plugin-rsc/e2e/browser-mode.test.ts
new file mode 100644
index 000000000..42e2e6d6a
--- /dev/null
+++ b/packages/plugin-rsc/e2e/browser-mode.test.ts
@@ -0,0 +1,74 @@
+import { expect, test, type Page } from '@playwright/test'
+import { useFixture } from './fixture'
+import { defineStarterTest } from './starter'
+
+test.describe('dev-browser-mode', () => {
+ // Webkit fails by
+ // > TypeError: ReadableByteStreamController is not implemented
+ test.skip(({ browserName }) => browserName === 'webkit')
+
+ const f = useFixture({ root: 'examples/browser-mode', mode: 'dev' })
+ defineStarterTest(f, 'browser-mode')
+
+ // action-bind tests copied from basic.test.ts
+
+ test('action bind simple', async ({ page }) => {
+ await page.goto(f.url())
+ await testActionBindSimple(page)
+ })
+
+ async function testActionBindSimple(page: Page) {
+ await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText(
+ '[?]',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-simple' })
+ .click()
+ await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText(
+ 'true',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-reset' })
+ .click()
+ }
+
+ test('action bind client', async ({ page }) => {
+ await page.goto(f.url())
+ await testActionBindClient(page)
+ })
+
+ async function testActionBindClient(page: Page) {
+ await expect(page.getByTestId('test-server-action-bind-client')).toHaveText(
+ '[?]',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-client' })
+ .click()
+ await expect(page.getByTestId('test-server-action-bind-client')).toHaveText(
+ 'true',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-reset' })
+ .click()
+ }
+
+ test('action bind action', async ({ page }) => {
+ await page.goto(f.url())
+ await testActionBindAction(page)
+ })
+
+ async function testActionBindAction(page: Page) {
+ await expect(page.getByTestId('test-server-action-bind-action')).toHaveText(
+ '[?]',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-action' })
+ .click()
+ await expect(page.getByTestId('test-server-action-bind-action')).toHaveText(
+ '[true,true]',
+ )
+ await page
+ .getByRole('button', { name: 'test-server-action-bind-reset' })
+ .click()
+ }
+})
diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts
index 702c5b7ec..60c3aa4f2 100644
--- a/packages/plugin-rsc/e2e/helper.ts
+++ b/packages/plugin-rsc/e2e/helper.ts
@@ -15,7 +15,7 @@ export async function waitForHydration(page: Page, locator: string = 'body') {
el &&
Object.keys(el).some((key) => key.startsWith('__reactFiber')),
),
- { timeout: 3000 },
+ { timeout: 10000 },
)
.toBeTruthy()
}
diff --git a/packages/plugin-rsc/e2e/starter.ts b/packages/plugin-rsc/e2e/starter.ts
index 68b05706f..3f2e02378 100644
--- a/packages/plugin-rsc/e2e/starter.ts
+++ b/packages/plugin-rsc/e2e/starter.ts
@@ -9,10 +9,13 @@ import {
export function defineStarterTest(
f: Fixture,
- variant?: 'no-ssr' | 'dev-production',
+ variant?: 'no-ssr' | 'dev-production' | 'browser-mode',
) {
const waitForHydration: typeof waitForHydration_ = (page) =>
- waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body')
+ waitForHydration_(
+ page,
+ variant === 'no-ssr' || variant === 'browser-mode' ? '#root' : 'body',
+ )
test('basic', async ({ page }) => {
using _ = expectNoPageError(page)
@@ -40,7 +43,7 @@ export function defineStarterTest(
})
testNoJs('server action @nojs', async ({ page }) => {
- test.skip(variant === 'no-ssr')
+ test.skip(variant === 'no-ssr' || variant === 'browser-mode')
await page.goto(f.url())
await page.getByRole('button', { name: 'Server Counter: 1' }).click()
@@ -50,7 +53,11 @@ export function defineStarterTest(
})
test('client hmr', async ({ page }) => {
- test.skip(f.mode === 'build' || variant === 'dev-production')
+ test.skip(
+ f.mode === 'build' ||
+ variant === 'dev-production' ||
+ variant === 'browser-mode',
+ )
await page.goto(f.url())
await waitForHydration(page)
@@ -80,7 +87,7 @@ export function defineStarterTest(
})
test.describe(() => {
- test.skip(f.mode === 'build')
+ test.skip(f.mode === 'build' || variant === 'browser-mode')
test('server hmr', async ({ page }) => {
await page.goto(f.url())
@@ -113,20 +120,17 @@ export function defineStarterTest(
test('css @js', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
- await expect(page.locator('.read-the-docs')).toHaveCSS(
- 'color',
- 'rgb(136, 136, 136)',
- )
+ await expect(page.locator('.card').nth(0)).toHaveCSS('padding-left', '16px')
})
test.describe(() => {
- test.skip(variant === 'no-ssr')
+ test.skip(variant === 'no-ssr' || variant === 'browser-mode')
testNoJs('css @nojs', async ({ page }) => {
await page.goto(f.url())
- await expect(page.locator('.read-the-docs')).toHaveCSS(
- 'color',
- 'rgb(136, 136, 136)',
+ await expect(page.locator('.card').nth(0)).toHaveCSS(
+ 'padding-left',
+ '16px',
)
})
})
diff --git a/packages/plugin-rsc/e2e/syntax-error.test.ts b/packages/plugin-rsc/e2e/syntax-error.test.ts
index 75425713c..a44980d95 100644
--- a/packages/plugin-rsc/e2e/syntax-error.test.ts
+++ b/packages/plugin-rsc/e2e/syntax-error.test.ts
@@ -164,11 +164,11 @@ test.describe(() => {
)
await expect(async () => {
await page.goto(f.url())
- await waitForHydration(page)
await expect(page.getByTestId('client-content')).toHaveText(
'client:fixed',
)
}).toPass()
+ await waitForHydration(page)
})
})
@@ -197,11 +197,11 @@ test.describe(() => {
)
await expect(async () => {
await page.goto(f.url())
- await waitForHydration(page)
await expect(page.getByTestId('server-content')).toHaveText(
'server:fixed',
)
}).toPass()
+ await waitForHydration(page)
})
})
})
diff --git a/packages/plugin-rsc/examples/browser-mode/README.md b/packages/plugin-rsc/examples/browser-mode/README.md
new file mode 100644
index 000000000..2e9ed6455
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/README.md
@@ -0,0 +1 @@
+[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) but entirely on Browser. Inspired by https://github.com/kasperpeulen/vitest-plugin-rsc/
diff --git a/packages/plugin-rsc/examples/browser-mode/index.html b/packages/plugin-rsc/examples/browser-mode/index.html
new file mode 100644
index 000000000..6323c94f5
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ RSC Browser Mode
+
+
+
+
+
+
+
+
diff --git a/packages/plugin-rsc/examples/browser-mode/package.json b/packages/plugin-rsc/examples/browser-mode/package.json
new file mode 100644
index 000000000..a4f517d27
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@vitejs/plugin-rsc-examples-browser-mode",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "false && vite build",
+ "preview": "false && vite preview"
+ },
+ "dependencies": {
+ "@vitejs/plugin-rsc": "latest",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "latest",
+ "vite": "^7.0.6"
+ }
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/public/vite.svg b/packages/plugin-rsc/examples/browser-mode/public/vite.svg
new file mode 100644
index 000000000..e7b8dfb1b
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx
new file mode 100644
index 000000000..2fe0c81c6
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx
@@ -0,0 +1,12 @@
+'use client'
+
+import React from 'react'
+
+export function ActionBindClient() {
+ const hydrated = React.useSyncExternalStore(
+ React.useCallback(() => () => {}, []),
+ () => true,
+ () => false,
+ )
+ return <>{String(hydrated)}>
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx
new file mode 100644
index 000000000..1b1675c3a
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx
@@ -0,0 +1,16 @@
+'use client'
+
+import React from 'react'
+
+export function TestServerActionBindClientForm(props: {
+ action: () => Promise
+}) {
+ const [result, formAction] = React.useActionState(props.action, '[?]')
+
+ return (
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx
new file mode 100644
index 000000000..dda9ee2ed
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx
@@ -0,0 +1,107 @@
+// based on test cases in
+// https://github.com/vercel/next.js/blob/ad898de735c393d98960a68c8d9eaeee32206c57/test/e2e/app-dir/actions/app/encryption/page.js
+
+import { ActionBindClient } from './client'
+import { TestServerActionBindClientForm } from './form'
+
+export function TestActionBind() {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+export function TestServerActionBindReset() {
+ return (
+
+ )
+}
+
+let testServerActionBindSimpleState = '[?]'
+
+export function TestServerActionBindSimple() {
+ const outerValue = 'outerValue'
+
+ return (
+
+ )
+}
+
+let testServerActionBindClientState = 0
+
+export function TestServerActionBindClient() {
+ // client element as server action bound argument
+ const client =
+
+ const action = async () => {
+ 'use server'
+ return client
+ }
+
+ return (
+
+ )
+}
+
+let testServerActionBindActionState = '[?]'
+
+export function TestServerActionBindAction() {
+ async function otherAction() {
+ 'use server'
+ return 'otherActionValue'
+ }
+
+ function wrapAction(value: string, action: () => Promise) {
+ return async function (formValue: string) {
+ 'use server'
+ const actionValue = await action()
+ return [actionValue === 'otherActionValue', formValue === value]
+ }
+ }
+
+ const action = wrapAction('ok', otherAction)
+
+ return (
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx
new file mode 100644
index 000000000..a72eb0bda
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx
@@ -0,0 +1,5 @@
+'use server'
+
+export async function testActionState(prev: number) {
+ return prev + 1
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx
new file mode 100644
index 000000000..aca850f2f
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import React from 'react'
+import { testActionState } from './action'
+
+export function TestUseActionState() {
+ const [state, formAction] = React.useActionState(testActionState, 0)
+
+ return (
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/action.tsx b/packages/plugin-rsc/examples/browser-mode/src/action.tsx
new file mode 100644
index 000000000..4fc55d65b
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/action.tsx
@@ -0,0 +1,11 @@
+'use server'
+
+let serverCounter = 0
+
+export async function getServerCounter() {
+ return serverCounter
+}
+
+export async function updateServerCounter(change: number) {
+ serverCounter += change
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg b/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg
new file mode 100644
index 000000000..6c87de9bb
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/plugin-rsc/examples/browser-mode/src/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/client.tsx
new file mode 100644
index 000000000..29bb5d367
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/client.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import React from 'react'
+
+export function ClientCounter() {
+ const [count, setCount] = React.useState(0)
+
+ return (
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx
new file mode 100644
index 000000000..bb36e3626
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx
@@ -0,0 +1,57 @@
+import * as React from 'react'
+import * as ReactDOMClient from 'react-dom/client'
+import * as ReactClient from '@vitejs/plugin-rsc/react/browser'
+import type { RscPayload } from './entry.rsc'
+
+let fetchServer: typeof import('./entry.rsc').fetchServer
+
+export function initialize(options: { fetchServer: typeof fetchServer }) {
+ fetchServer = options.fetchServer
+ ReactClient.setRequireModule({
+ load: (id) => import(/* @vite-ignore */ id),
+ })
+}
+
+export async function main() {
+ let setPayload: (v: RscPayload) => void
+
+ const initialPayload = await ReactClient.createFromFetch(
+ fetchServer(new Request(window.location.href)),
+ )
+
+ function BrowserRoot() {
+ const [payload, setPayload_] = React.useState(initialPayload)
+
+ React.useEffect(() => {
+ setPayload = (v) => React.startTransition(() => setPayload_(v))
+ }, [setPayload_])
+
+ return payload.root
+ }
+
+ ReactClient.setServerCallback(async (id, args) => {
+ const url = new URL(window.location.href)
+ const temporaryReferences = ReactClient.createTemporaryReferenceSet()
+ const payload = await ReactClient.createFromFetch(
+ fetchServer(
+ new Request(url, {
+ method: 'POST',
+ body: await ReactClient.encodeReply(args, { temporaryReferences }),
+ headers: {
+ 'x-rsc-action': id,
+ },
+ }),
+ ),
+ { temporaryReferences },
+ )
+ setPayload(payload)
+ return payload.returnValue
+ })
+
+ const browserRoot = (
+
+
+
+ )
+ ReactDOMClient.createRoot(document.body).render(browserRoot)
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx
new file mode 100644
index 000000000..343e6751c
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx
@@ -0,0 +1,55 @@
+import * as ReactServer from '@vitejs/plugin-rsc/react/rsc'
+import type React from 'react'
+import { Root } from '../root'
+import type { ReactFormState } from 'react-dom/client'
+
+export type RscPayload = {
+ root: React.ReactNode
+ returnValue?: unknown
+ formState?: ReactFormState
+}
+
+declare let __vite_rsc_raw_import__: (id: string) => Promise
+
+export function initialize() {
+ ReactServer.setRequireModule({ load: (id) => __vite_rsc_raw_import__(id) })
+}
+
+export async function fetchServer(request: Request): Promise {
+ const isAction = request.method === 'POST'
+ let returnValue: unknown | undefined
+ let formState: ReactFormState | undefined
+ let temporaryReferences: unknown | undefined
+ if (isAction) {
+ const actionId = request.headers.get('x-rsc-action')
+ if (actionId) {
+ const contentType = request.headers.get('content-type')
+ const body = contentType?.startsWith('multipart/form-data')
+ ? await request.formData()
+ : await request.text()
+ temporaryReferences = ReactServer.createTemporaryReferenceSet()
+ const args = await ReactServer.decodeReply(body, { temporaryReferences })
+ const action = await ReactServer.loadServerAction(actionId)
+ returnValue = await action.apply(null, args)
+ } else {
+ const formData = await request.formData()
+ const decodedAction = await ReactServer.decodeAction(formData)
+ const result = await decodedAction()
+ formState = await ReactServer.decodeFormState(result, formData)
+ }
+ }
+
+ const rscPayload: RscPayload = { root: , formState, returnValue }
+ const rscOptions = { temporaryReferences }
+ const rscStream = ReactServer.renderToReadableStream(
+ rscPayload,
+ rscOptions,
+ )
+
+ return new Response(rscStream, {
+ headers: {
+ 'content-type': 'text/x-component;charset=utf-8',
+ vary: 'accept',
+ },
+ })
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx
new file mode 100644
index 000000000..12d5c7546
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx
@@ -0,0 +1,35 @@
+import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner'
+import * as server from './entry.rsc'
+
+async function main() {
+ const client = await importClient()
+ server.initialize()
+ client.initialize({ fetchServer: server.fetchServer })
+ await client.main()
+}
+
+async function importClient() {
+ const runner = new ModuleRunner(
+ {
+ sourcemapInterceptor: false,
+ transport: {
+ invoke: async (payload) => {
+ const response = await fetch(
+ '/@vite/invoke-react-client?' +
+ new URLSearchParams({
+ data: JSON.stringify(payload),
+ }),
+ )
+ return response.json()
+ },
+ },
+ hmr: false,
+ },
+ new ESModulesEvaluator(),
+ )
+ return await runner.import(
+ '/src/framework/entry.browser.tsx',
+ )
+}
+
+main()
diff --git a/packages/plugin-rsc/examples/browser-mode/src/index.css b/packages/plugin-rsc/examples/browser-mode/src/index.css
new file mode 100644
index 000000000..f4d2128c0
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/index.css
@@ -0,0 +1,112 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
+
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 1rem;
+}
+
+.read-the-docs {
+ color: #888;
+ text-align: left;
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/src/root.tsx b/packages/plugin-rsc/examples/browser-mode/src/root.tsx
new file mode 100644
index 000000000..e8d912527
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/src/root.tsx
@@ -0,0 +1,44 @@
+import './index.css'
+import viteLogo from '/vite.svg'
+import { getServerCounter, updateServerCounter } from './action.tsx'
+import reactLogo from './assets/react.svg'
+import { ClientCounter } from './client.tsx'
+import { TestUseActionState } from './action-from-client/client.tsx'
+import { TestActionBind } from './action-bind/server.tsx'
+
+export function Root() {
+ return
+}
+
+function App() {
+ return (
+
+
+
Vite + RSC
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/tsconfig.json b/packages/plugin-rsc/examples/browser-mode/tsconfig.json
new file mode 100644
index 000000000..4c355ed3c
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "@vitejs/plugin-rsc/types"],
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts
new file mode 100644
index 000000000..c8bf161f4
--- /dev/null
+++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts
@@ -0,0 +1,104 @@
+import { defaultClientConditions, defineConfig } from 'vite'
+import { vitePluginRscMinimal } from '@vitejs/plugin-rsc/plugin'
+// import inspect from 'vite-plugin-inspect'
+
+export default defineConfig({
+ plugins: [
+ // inspect(),
+ vitePluginRscMinimal({
+ environment: {
+ rsc: 'client',
+ browser: 'react_client',
+ },
+ }),
+ {
+ name: 'rsc:browser-mode',
+ configureServer(server) {
+ server.middlewares.use(async (req, res, next) => {
+ const url = new URL(req.url ?? '/', 'https://any.local')
+ if (url.pathname === '/@vite/invoke-react-client') {
+ const payload = JSON.parse(url.searchParams.get('data')!)
+ const result =
+ await server.environments['react_client']!.hot.handleInvoke(
+ payload,
+ )
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify(result))
+ return
+ }
+ next()
+ })
+ },
+ // for "react_client" hmr, it requires:
+ // - enable fast-refresh transform on `react_client` environment
+ // - currently `@vitejs/plugin-react` doesn't support it
+ // - implement and enable module runner hmr
+ hotUpdate(ctx) {
+ if (this.environment.name === 'react_client') {
+ if (ctx.modules.length > 0) {
+ ctx.server.environments.client.hot.send({
+ type: 'full-reload',
+ path: ctx.file,
+ })
+ }
+ }
+ },
+ config() {
+ return {
+ environments: {
+ client: {
+ keepProcessEnv: false,
+ resolve: {
+ conditions: ['react-server', ...defaultClientConditions],
+ },
+ optimizeDeps: {
+ include: [
+ 'react',
+ 'react-dom',
+ 'react-dom/client',
+ 'react/jsx-runtime',
+ 'react/jsx-dev-runtime',
+ '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge',
+ '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge',
+ // TODO: browser build breaks `src/actin-bind` examples
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/server.browser',
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser',
+ ],
+ exclude: ['vite', '@vitejs/plugin-rsc'],
+ },
+ },
+ react_client: {
+ keepProcessEnv: false,
+ resolve: {
+ conditions: [...defaultClientConditions],
+ noExternal: true,
+ },
+ optimizeDeps: {
+ include: [
+ 'react',
+ 'react-dom',
+ 'react-dom/client',
+ 'react/jsx-runtime',
+ 'react/jsx-dev-runtime',
+ '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser',
+ ],
+ exclude: ['@vitejs/plugin-rsc'],
+ esbuildOptions: {
+ platform: 'browser',
+ },
+ },
+ },
+ },
+ resolve: {
+ // alias: {
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge':
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/server.browser',
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge':
+ // '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser',
+ // },
+ },
+ }
+ },
+ },
+ ],
+})
diff --git a/packages/plugin-rsc/package.json b/packages/plugin-rsc/package.json
index 01e4e08be..41a8f18a0 100644
--- a/packages/plugin-rsc/package.json
+++ b/packages/plugin-rsc/package.json
@@ -60,7 +60,8 @@
"react-server-dom-webpack": "^19.1.0",
"rsc-html-stream": "^0.0.7",
"tinyexec": "^1.0.1",
- "tsdown": "^0.13.0"
+ "tsdown": "^0.13.0",
+ "vite-plugin-inspect": "^11.3.2"
},
"peerDependencies": {
"react": "*",
diff --git a/packages/plugin-rsc/playwright.config.ts b/packages/plugin-rsc/playwright.config.ts
index 13549cb7e..5c390e9de 100644
--- a/packages/plugin-rsc/playwright.config.ts
+++ b/packages/plugin-rsc/playwright.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
trace: 'on-first-retry',
},
expect: {
- toPass: { timeout: 5000 },
+ toPass: { timeout: 10000 },
},
projects: [
{
diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts
index e5e87e35f..b9b28de71 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -131,6 +131,55 @@ export type RscPluginOptions = {
* @default false
*/
useBuildAppHook?: boolean
+
+ /**
+ * Custom environment configuration
+ * @experimental
+ * @default { browser: 'client', ssr: 'ssr', rsc: 'rsc' }
+ */
+ environment?: {
+ browser?: string
+ ssr?: string
+ rsc?: string
+ }
+}
+
+/** @experimental */
+export function vitePluginRscMinimal(
+ rscPluginOptions: RscPluginOptions = {},
+): Plugin[] {
+ return [
+ {
+ name: 'rsc:minimal',
+ enforce: 'pre',
+ async config() {
+ await esModuleLexer.init
+ },
+ configResolved(config_) {
+ config = config_
+ },
+ configureServer(server_) {
+ server = server_
+ },
+ },
+ {
+ name: 'rsc:vite-client-raw-import',
+ transform: {
+ order: 'post',
+ handler(code) {
+ if (code.includes('__vite_rsc_raw_import__')) {
+ // inject dynamic import last to avoid Vite adding `?import` query
+ // to client references (and browser mode server references)
+ return code.replace('__vite_rsc_raw_import__', 'import')
+ }
+ },
+ },
+ },
+ ...vitePluginRscCore(),
+ ...vitePluginUseClient(rscPluginOptions),
+ ...vitePluginUseServer(rscPluginOptions),
+ ...vitePluginDefineEncryptionKey(rscPluginOptions),
+ ]
}
export default function vitePluginRsc(
@@ -186,8 +235,6 @@ export default function vitePluginRsc(
{
name: 'rsc',
async config(config, env) {
- await esModuleLexer.init
-
// crawl packages with "react" in "peerDependencies" to bundle react deps on server
// see https://github.com/svitejs/vitefu/blob/d8d82fa121e3b2215ba437107093c77bde51b63b/src/index.js#L95-L101
const result = await crawlFrameworkPkgs({
@@ -295,11 +342,7 @@ export default function vitePluginRsc(
}
},
buildApp: rscPluginOptions.useBuildAppHook ? buildApp : undefined,
- configResolved(config_) {
- config = config_
- },
- configureServer(server_) {
- server = server_
+ configureServer() {
;(globalThis as any).__viteRscDevServer = server
if (rscPluginOptions.disableServerHandler) return
@@ -455,18 +498,6 @@ export default function vitePluginRsc(
}
},
},
- {
- name: 'rsc:patch-browser-raw-import',
- transform: {
- order: 'post',
- handler(code) {
- if (code.includes('__vite_rsc_raw_import__')) {
- // inject dynamic import last to avoid Vite adding `?import` query to client references
- return code.replace('__vite_rsc_raw_import__', 'import')
- }
- },
- },
- },
{
// backward compat: `loadSsrModule(name)` implemented as `loadModule("ssr", name)`
name: 'rsc:load-ssr-module',
@@ -851,10 +882,7 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage;
return ''
},
},
- ...vitePluginRscCore(),
- ...vitePluginUseClient(rscPluginOptions),
- ...vitePluginUseServer(rscPluginOptions),
- ...vitePluginDefineEncryptionKey(rscPluginOptions),
+ ...vitePluginRscMinimal(rscPluginOptions),
...vitePluginFindSourceMapURL(),
...vitePluginRscCss({ rscCssTransform: rscPluginOptions.rscCssTransform }),
...(rscPluginOptions.validateImports !== false
@@ -941,7 +969,7 @@ function hashString(v: string) {
function vitePluginUseClient(
useClientPluginOptions: Pick<
RscPluginOptions,
- 'ignoredPackageWarnings' | 'keepUseCientProxy'
+ 'ignoredPackageWarnings' | 'keepUseCientProxy' | 'environment'
>,
): Plugin[] {
const packageSources = new Map()
@@ -949,11 +977,15 @@ function vitePluginUseClient(
// https://github.com/vitejs/vite/blob/4bcf45863b5f46aa2b41f261283d08f12d3e8675/packages/vite/src/node/utils.ts#L175
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/
+ const serverEnvironmentName = useClientPluginOptions.environment?.rsc ?? 'rsc'
+ const browserEnvironmentName =
+ useClientPluginOptions.environment?.browser ?? 'client'
+
return [
{
name: 'rsc:use-client',
async transform(code, id) {
- if (this.environment.name !== 'rsc') return
+ if (this.environment.name !== serverEnvironmentName) return
if (!code.includes('use client')) return
const ast = await parseAstAsync(code)
@@ -996,7 +1028,7 @@ function vitePluginUseClient(
} else {
if (this.environment.mode === 'dev') {
importId = normalizeViteImportAnalysisUrl(
- server.environments.client,
+ server.environments[browserEnvironmentName]!,
id,
)
referenceKey = importId
@@ -1075,7 +1107,7 @@ function vitePluginUseClient(
id.startsWith('\0virtual:vite-rsc/client-in-server-package-proxy/')
) {
assert.equal(this.environment.mode, 'dev')
- assert.notEqual(this.environment.name, 'rsc')
+ assert(this.environment.name !== serverEnvironmentName)
id = decodeURIComponent(
id.slice(
'\0virtual:vite-rsc/client-in-server-package-proxy/'.length,
@@ -1095,7 +1127,10 @@ function vitePluginUseClient(
resolveId: {
order: 'pre',
async handler(source, importer, options) {
- if (this.environment.name === 'rsc' && bareImportRE.test(source)) {
+ if (
+ this.environment.name === serverEnvironmentName &&
+ bareImportRE.test(source)
+ ) {
const resolved = await this.resolve(source, importer, options)
if (resolved && resolved.id.includes('/node_modules/')) {
packageSources.set(resolved.id, source)
@@ -1120,7 +1155,7 @@ function vitePluginUseClient(
}
},
generateBundle(_options, bundle) {
- if (this.environment.name !== 'rsc') return
+ if (this.environment.name !== serverEnvironmentName) return
// track used exports of client references in rsc build
// to tree shake unused exports in browser and ssr build
@@ -1140,18 +1175,23 @@ function vitePluginUseClient(
}
function vitePluginDefineEncryptionKey(
- useServerPluginOptions: Pick,
+ useServerPluginOptions: Pick<
+ RscPluginOptions,
+ 'defineEncryptionKey' | 'environment'
+ >,
): Plugin[] {
let defineEncryptionKey: string
let emitEncryptionKey = false
const KEY_PLACEHOLDER = '__vite_rsc_define_encryption_key'
const KEY_FILE = '__vite_rsc_encryption_key.js'
+ const serverEnvironmentName = useServerPluginOptions.environment?.rsc ?? 'rsc'
+
return [
{
name: 'rsc:encryption-key',
async configEnvironment(name, _config, env) {
- if (name === 'rsc' && !env.isPreview) {
+ if (name === serverEnvironmentName && !env.isPreview) {
defineEncryptionKey =
useServerPluginOptions.defineEncryptionKey ??
JSON.stringify(toBase64(await generateEncryptionKey()))
@@ -1201,9 +1241,13 @@ function vitePluginDefineEncryptionKey(
function vitePluginUseServer(
useServerPluginOptions: Pick<
RscPluginOptions,
- 'ignoredPackageWarnings' | 'enableActionEncryption'
+ 'ignoredPackageWarnings' | 'enableActionEncryption' | 'environment'
>,
): Plugin[] {
+ const serverEnvironmentName = useServerPluginOptions.environment?.rsc ?? 'rsc'
+ const browserEnvironmentName =
+ useServerPluginOptions.environment?.browser ?? 'client'
+
return [
{
name: 'rsc:use-server',
@@ -1236,7 +1280,7 @@ function vitePluginUseServer(
normalizedId_ = hashString(path.relative(config.root, id))
} else {
normalizedId_ = normalizeViteImportAnalysisUrl(
- server.environments.rsc!,
+ server.environments[serverEnvironmentName]!,
id,
)
}
@@ -1244,7 +1288,7 @@ function vitePluginUseServer(
return normalizedId_
}
- if (this.environment.name === 'rsc') {
+ if (this.environment.name === serverEnvironmentName) {
const transformServerActionServer_ = withRollupError(
this,
transformServerActionServer,
@@ -1305,7 +1349,8 @@ function vitePluginUseServer(
const output = result?.output
if (!output?.hasChanged()) return
serverReferences[getNormalizedId()] = id
- const name = this.environment.name === 'client' ? 'browser' : 'ssr'
+ const name =
+ this.environment.name === browserEnvironmentName ? 'browser' : 'ssr'
const importSource = resolvePackage(`${PKG_NAME}/react/${name}`)
output.prepend(`import * as $$ReactClient from "${importSource}";\n`)
return {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0406d1e0d..889b162a4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -502,6 +502,9 @@ importers:
tsdown:
specifier: ^0.13.0
version: 0.13.0(publint@0.3.12)(typescript@5.8.3)
+ vite-plugin-inspect:
+ specifier: ^11.3.2
+ version: 11.3.2(vite@7.0.6(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1))
packages/plugin-rsc/examples/basic:
dependencies:
@@ -561,6 +564,31 @@ importers:
specifier: ^4.26.0
version: 4.26.0
+ packages/plugin-rsc/examples/browser-mode:
+ dependencies:
+ '@vitejs/plugin-rsc':
+ specifier: latest
+ version: link:../..
+ react:
+ specifier: ^19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.1.0(react@19.1.0)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.8
+ version: 19.1.8
+ '@types/react-dom':
+ specifier: ^19.1.6
+ version: 19.1.6(@types/react@19.1.8)
+ '@vitejs/plugin-react':
+ specifier: latest
+ version: link:../../../plugin-react
+ vite:
+ specifier: ^7.0.6
+ version: 7.0.6(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1)
+
packages/plugin-rsc/examples/e2e:
devDependencies:
'@vitejs/plugin-react':
@@ -8615,7 +8643,7 @@ snapshots:
unplugin-utils@0.2.4:
dependencies:
pathe: 2.0.3
- picomatch: 4.0.2
+ picomatch: 4.0.3
unrs-resolver@1.9.2:
dependencies: