diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index fdb366064..b1b167e38 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -4,12 +4,12 @@ export const testNoJs = test.extend({ javaScriptEnabled: ({}, use) => use(false), }) -export async function waitForHydration(page: Page) { +export async function waitForHydration(page: Page, locator: string = 'body') { await expect .poll( () => page - .locator('body') + .locator(locator) .evaluate( (el) => el && diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index f0d6aa233..1ee2c2e0e 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -1,6 +1,12 @@ import { expect, test } from '@playwright/test' import { type Fixture, useFixture } from './fixture' -import { expectNoReload, testNoJs, waitForHydration } from './helper' +import { + expectNoReload, + testNoJs, + waitForHydration as waitForHydration_, +} from './helper' +import path from 'node:path' +import fs from 'node:fs' test.describe('dev-default', () => { const f = useFixture({ root: 'examples/starter', mode: 'dev' }) @@ -22,7 +28,24 @@ test.describe('build-cloudflare', () => { defineTest(f) }) -function defineTest(f: Fixture) { +test.describe('dev-no-ssr', () => { + const f = useFixture({ root: 'examples/no-ssr', mode: 'dev' }) + defineTest(f, 'no-ssr') +}) + +test.describe('build-no-ssr', () => { + const f = useFixture({ root: 'examples/no-ssr', mode: 'build' }) + defineTest(f, 'no-ssr') + + test('no ssr build', () => { + expect(fs.existsSync(path.join(f.root, 'dist/ssr'))).toBe(false) + }) +}) + +function defineTest(f: Fixture, variant?: 'no-ssr') { + const waitForHydration: typeof waitForHydration_ = (page) => + waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body') + test('basic', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) @@ -48,6 +71,8 @@ function defineTest(f: Fixture) { }) testNoJs('server action @nojs', async ({ page }) => { + test.skip(variant === 'no-ssr') + await page.goto(f.url()) await page.getByRole('button', { name: 'Server Counter: 1' }).click() await expect( @@ -71,6 +96,12 @@ function defineTest(f: Fixture) { page.getByRole('button', { name: 'Client [edit] Counter: 1' }), ).toBeVisible() + if (variant === 'no-ssr') { + editor.reset() + await page.getByRole('button', { name: 'Client Counter: 1' }).click() + return + } + // check next ssr is also updated const res = await page.goto(f.url()) expect(await res?.text()).toContain('Client [edit] Counter') diff --git a/packages/plugin-rsc/examples/no-ssr/README.md b/packages/plugin-rsc/examples/no-ssr/README.md new file mode 100644 index 000000000..db13dfe8d --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/README.md @@ -0,0 +1 @@ +[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) without SSR environment diff --git a/packages/plugin-rsc/examples/no-ssr/index.html b/packages/plugin-rsc/examples/no-ssr/index.html new file mode 100644 index 000000000..01b0331d7 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/index.html @@ -0,0 +1,12 @@ + + + + + + + + + +
+ + diff --git a/packages/plugin-rsc/examples/no-ssr/package.json b/packages/plugin-rsc/examples/no-ssr/package.json new file mode 100644 index 000000000..64a3f79b4 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-no-ssr", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "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.2" + } +} diff --git a/packages/plugin-rsc/examples/no-ssr/public/vite.svg b/packages/plugin-rsc/examples/no-ssr/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/no-ssr/src/action.tsx b/packages/plugin-rsc/examples/no-ssr/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/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/no-ssr/src/assets/react.svg b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/no-ssr/src/client.tsx b/packages/plugin-rsc/examples/no-ssr/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/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/no-ssr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx new file mode 100644 index 000000000..0d3451c56 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx @@ -0,0 +1,122 @@ +import * as ReactClient from '@vitejs/plugin-rsc/browser' +import React from 'react' +import * as ReactDOMClient from 'react-dom/client' +import type { RscPayload } from './entry.rsc' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + const initialPayload = await ReactClient.createFromFetch( + fetch(window.location.href), + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await ReactClient.createFromFetch( + fetch(window.location.href), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + ReactClient.setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = ReactClient.createTemporaryReferenceSet() + const payload = await ReactClient.createFromFetch( + fetch(url, { + method: 'POST', + body: await ReactClient.encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + ReactDOMClient.createRoot(document.body).render(browserRoot) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..da968de3a --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx @@ -0,0 +1,51 @@ +import * as ReactServer from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +export default async function handler(request: Request): Promise { + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = 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 rscStream = ReactServer.renderToReadableStream({ + root: , + returnValue, + formState, + }) + + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/no-ssr/src/index.css b/packages/plugin-rsc/examples/no-ssr/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/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/no-ssr/src/root.tsx b/packages/plugin-rsc/examples/no-ssr/src/root.tsx new file mode 100644 index 000000000..9baa7b9c2 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/root.tsx @@ -0,0 +1,44 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root() { + return +} + +function App() { + return ( +
+ +

Vite + RSC

+
+ +
+
+
+ +
+
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/no-ssr/tsconfig.json b/packages/plugin-rsc/examples/no-ssr/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/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/no-ssr/vite.config.ts b/packages/plugin-rsc/examples/no-ssr/vite.config.ts new file mode 100644 index 000000000..ce349c6e9 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/vite.config.ts @@ -0,0 +1,65 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig, type Plugin } from 'vite' +import fsp from 'node:fs/promises' + +export default defineConfig({ + plugins: [ + spaPlugin(), + react(), + rsc({ + entries: { + rsc: './src/framework/entry.rsc.tsx', + }, + }), + ], +}) + +function spaPlugin(): Plugin[] { + // serve index.html before rsc server + return [ + { + name: 'serve-spa', + configureServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + try { + if (req.headers.accept?.includes('text/html')) { + const html = await fsp.readFile('index.html', 'utf-8') + const transformed = await server.transformIndexHtml('/', html) + res.setHeader('Content-type', 'text/html') + res.setHeader('Vary', 'accept') + res.end(transformed) + return + } + } catch (error) { + next(error) + return + } + next() + }) + } + }, + configurePreviewServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + try { + if (req.headers.accept?.includes('text/html')) { + const html = await fsp.readFile( + 'dist/client/index.html', + 'utf-8', + ) + res.end(html) + return + } + } catch (error) { + next(error) + return + } + next() + }) + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 8698f7283..d53f31e15 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -220,10 +220,42 @@ export default function vitePluginRsc( }, }, }, + // TODO: use buildApp hook on v7? builder: { sharedPlugins: true, sharedConfigBuild: true, async buildApp(builder) { + // no-ssr case + // rsc -> client -> rsc -> client + if (!builder.environments.ssr?.config.build.rollupOptions.input) { + isScanBuild = true + builder.environments.rsc!.config.build.write = false + builder.environments.client!.config.build.write = false + await builder.build(builder.environments.rsc!) + await builder.build(builder.environments.client!) + isScanBuild = false + builder.environments.rsc!.config.build.write = true + builder.environments.client!.config.build.write = true + await builder.build(builder.environments.rsc!) + // sort for stable build + clientReferenceMetaMap = sortObject(clientReferenceMetaMap) + serverResourcesMetaMap = sortObject(serverResourcesMetaMap) + await builder.build(builder.environments.client!) + + const assetsManifestCode = `export default ${JSON.stringify( + buildAssetsManifest, + null, + 2, + )}` + const manifestPath = path.join( + builder.environments!.rsc!.config.build!.outDir!, + BUILD_ASSETS_MANIFEST_NAME, + ) + fs.writeFileSync(manifestPath, assetsManifestCode) + return + } + + // rsc -> ssr -> rsc -> client -> ssr isScanBuild = true builder.environments.rsc!.config.build.write = false builder.environments.ssr!.config.build.write = false @@ -632,6 +664,8 @@ export default function vitePluginRsc( return }, writeBundle() { + // TODO: move this to `buildApp`. + // note that we already do this in buildApp for no-ssr case. if (this.environment.name === 'ssr') { // output client manifest to non-client build directly. // this makes server build to be self-contained and deploy-able for cloudflare. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 388dcc123..d710fb269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -558,6 +558,31 @@ importers: specifier: ^4.23.0 version: 4.23.0 + packages/plugin-rsc/examples/no-ssr: + 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.2 + version: 7.0.2(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1) + packages/plugin-rsc/examples/react-router: dependencies: '@vitejs/plugin-rsc':