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 ( +
+ + {result} +
+ ) +} 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 ( +
{ + 'use server' + testServerActionBindSimpleState = '[?]' + testServerActionBindActionState = '[?]' + testServerActionBindClientState++ + }} + > + +
+ ) +} + +let testServerActionBindSimpleState = '[?]' + +export function TestServerActionBindSimple() { + const outerValue = 'outerValue' + + return ( +
{ + 'use server' + const result = String(formData.get('value')) === outerValue + testServerActionBindSimpleState = JSON.stringify(result) + }} + > + + + + {testServerActionBindSimpleState} + +
+ ) +} + +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 ( +
{ + 'use server' + const result = await action(String(formData.get('value'))) + testServerActionBindActionState = JSON.stringify(result) + }} + > + + + + {testServerActionBindActionState} + +
+ ) +} 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: