From d9c4efbc7707be657f843ddf5951715918d60ad5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Fri, 11 Jul 2025 10:06:24 -0700 Subject: [PATCH 01/14] fix: support setups without an SSR environment When doing a "SPA" that fetches the RSC payload on boot the plugin currently falls over on production builds due to the lack of a configured rollup input. --- packages/plugin-rsc/src/plugin.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 8698f7283..57cd28551 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -226,18 +226,22 @@ export default function vitePluginRsc( async buildApp(builder) { isScanBuild = true builder.environments.rsc!.config.build.write = false - builder.environments.ssr!.config.build.write = false await builder.build(builder.environments.rsc!) - await builder.build(builder.environments.ssr!) + if (builder.environments.ssr?.config.build.rollupOptions.input) { + builder.environments.ssr!.config.build.write = false + await builder.build(builder.environments.ssr!) + } isScanBuild = false builder.environments.rsc!.config.build.write = true - builder.environments.ssr!.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!) - await builder.build(builder.environments.ssr!) + if (builder.environments.ssr?.config.build.rollupOptions.input) { + builder.environments.ssr!.config.build.write = true + await builder.build(builder.environments.ssr!) + } }, }, } From df27d867f79d6da6dc7088fd41f63565934d46c1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 11:46:52 +0900 Subject: [PATCH 02/14] test(rsc): add no ssr example --- packages/plugin-rsc/examples/no-ssr/README.md | 36 ++++++ .../plugin-rsc/examples/no-ssr/index.html | 9 ++ .../plugin-rsc/examples/no-ssr/package.json | 23 ++++ .../examples/no-ssr/public/vite.svg | 1 + .../plugin-rsc/examples/no-ssr/src/action.tsx | 11 ++ .../examples/no-ssr/src/assets/react.svg | 1 + .../plugin-rsc/examples/no-ssr/src/client.tsx | 13 ++ .../no-ssr/src/framework/entry.browser.tsx | 122 ++++++++++++++++++ .../no-ssr/src/framework/entry.rsc.tsx | 51 ++++++++ .../plugin-rsc/examples/no-ssr/src/index.css | 112 ++++++++++++++++ .../plugin-rsc/examples/no-ssr/src/root.tsx | 44 +++++++ .../plugin-rsc/examples/no-ssr/tsconfig.json | 18 +++ .../plugin-rsc/examples/no-ssr/vite.config.ts | 65 ++++++++++ 13 files changed, 506 insertions(+) create mode 100644 packages/plugin-rsc/examples/no-ssr/README.md create mode 100644 packages/plugin-rsc/examples/no-ssr/index.html create mode 100644 packages/plugin-rsc/examples/no-ssr/package.json create mode 100644 packages/plugin-rsc/examples/no-ssr/public/vite.svg create mode 100644 packages/plugin-rsc/examples/no-ssr/src/action.tsx create mode 100644 packages/plugin-rsc/examples/no-ssr/src/assets/react.svg create mode 100644 packages/plugin-rsc/examples/no-ssr/src/client.tsx create mode 100644 packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/no-ssr/src/index.css create mode 100644 packages/plugin-rsc/examples/no-ssr/src/root.tsx create mode 100644 packages/plugin-rsc/examples/no-ssr/tsconfig.json create mode 100644 packages/plugin-rsc/examples/no-ssr/vite.config.ts 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..15efa6164 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/README.md @@ -0,0 +1,36 @@ +# Vite + RSC + +This example shows how to setup 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 usages + +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) + - `@higoawa/vite-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` + - `@vitejs/plugin-rsc/rsc-html-stream/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `@vitejs/plugin-rsc/rsc-html-stream/browser` + +## 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. 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..7053b60ed --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/index.html @@ -0,0 +1,9 @@ + + + + + + + + + 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..009fefce1 --- /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).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..6cc9c3dc6 --- /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[] { + return [ + { + name: 'serve-spa', + configureServer(server) { + // pre-middleware for index.html request + 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() + }) + } + }, + }, + ] +} From 486c8164f841937484e027c5e40b200a2a5afb20 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 11:48:40 +0900 Subject: [PATCH 03/14] chore: lockfile --- packages/plugin-rsc/examples/no-ssr/README.md | 37 +------------------ pnpm-lock.yaml | 25 +++++++++++++ 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/plugin-rsc/examples/no-ssr/README.md b/packages/plugin-rsc/examples/no-ssr/README.md index 15efa6164..db13dfe8d 100644 --- a/packages/plugin-rsc/examples/no-ssr/README.md +++ b/packages/plugin-rsc/examples/no-ssr/README.md @@ -1,36 +1 @@ -# Vite + RSC - -This example shows how to setup 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 usages - -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) - - `@higoawa/vite-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` - - `@vitejs/plugin-rsc/rsc-html-stream/ssr` - - `import.meta.viteRsc.loadBootstrapScriptContent` -- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) - - `@vitejs/plugin-rsc/browser` - - `@vitejs/plugin-rsc/rsc-html-stream/browser` - -## 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. +[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) without SSR environment 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': From 2edef05e10082a2e3f822d25b8dd693316965ebe Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:01:18 +0900 Subject: [PATCH 04/14] fix: ensure __vite_rsc_assets_manifest.js --- packages/plugin-rsc/src/plugin.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 57cd28551..347be84a9 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -220,10 +220,39 @@ export default function vitePluginRsc( }, }, }, + // TODO: buildApp hook on v7 builder: { sharedPlugins: true, sharedConfigBuild: true, async buildApp(builder) { + 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 + } + isScanBuild = true builder.environments.rsc!.config.build.write = false await builder.build(builder.environments.rsc!) @@ -636,6 +665,7 @@ export default function vitePluginRsc( return }, writeBundle() { + // TODO: this doesn't happen when no-ssr build 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. From b599990c87322e87c86a0ca5dfda9700a897bed9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:29:39 +0900 Subject: [PATCH 05/14] test: add test --- packages/plugin-rsc/e2e/helper.ts | 2 +- packages/plugin-rsc/e2e/starter.test.ts | 21 ++++++++++++++++++- .../plugin-rsc/examples/no-ssr/index.html | 3 +++ .../no-ssr/src/framework/entry.browser.tsx | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index fdb366064..a4fc960d2 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -9,7 +9,7 @@ export async function waitForHydration(page: Page) { .poll( () => page - .locator('body') + .locator('body > *') .evaluate( (el) => el && diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index f0d6aa233..6778403a2 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -22,7 +22,18 @@ 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') +}) + +function defineTest(f: Fixture, variant?: 'no-ssr') { + f.root.includes('no-ssr') test('basic', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) @@ -48,6 +59,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 +84,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/index.html b/packages/plugin-rsc/examples/no-ssr/index.html index 7053b60ed..01b0331d7 100644 --- a/packages/plugin-rsc/examples/no-ssr/index.html +++ b/packages/plugin-rsc/examples/no-ssr/index.html @@ -6,4 +6,7 @@ + +
+ 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 index 009fefce1..0d3451c56 100644 --- a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx @@ -61,7 +61,7 @@ async function main() { ) - ReactDOMClient.createRoot(document).render(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) { From d5eeabd74552abceeadd6f3e1076ce2a92d195d9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:33:04 +0900 Subject: [PATCH 06/14] chore: comment --- packages/plugin-rsc/examples/no-ssr/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-rsc/examples/no-ssr/vite.config.ts b/packages/plugin-rsc/examples/no-ssr/vite.config.ts index 6cc9c3dc6..ce349c6e9 100644 --- a/packages/plugin-rsc/examples/no-ssr/vite.config.ts +++ b/packages/plugin-rsc/examples/no-ssr/vite.config.ts @@ -16,11 +16,11 @@ export default defineConfig({ }) function spaPlugin(): Plugin[] { + // serve index.html before rsc server return [ { name: 'serve-spa', configureServer(server) { - // pre-middleware for index.html request return () => { server.middlewares.use(async (req, res, next) => { try { From 7859e359e25388e1e0985a35025f10af90e25528 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:38:15 +0900 Subject: [PATCH 07/14] test: fix waitForHydration --- packages/plugin-rsc/e2e/helper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index a4fc960d2..1a4c4485a 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -10,6 +10,7 @@ export async function waitForHydration(page: Page) { () => page .locator('body > *') + .nth(0) .evaluate( (el) => el && From 2a8c5e23afe1e0b1ac23f739f35b40836ec9f885 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:48:33 +0900 Subject: [PATCH 08/14] chore: tweak --- packages/plugin-rsc/src/plugin.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 347be84a9..d53f31e15 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -220,11 +220,13 @@ export default function vitePluginRsc( }, }, }, - // TODO: buildApp hook on v7 + // 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 @@ -253,24 +255,21 @@ export default function vitePluginRsc( return } + // rsc -> ssr -> rsc -> client -> ssr isScanBuild = true builder.environments.rsc!.config.build.write = false + builder.environments.ssr!.config.build.write = false await builder.build(builder.environments.rsc!) - if (builder.environments.ssr?.config.build.rollupOptions.input) { - builder.environments.ssr!.config.build.write = false - await builder.build(builder.environments.ssr!) - } + await builder.build(builder.environments.ssr!) isScanBuild = false builder.environments.rsc!.config.build.write = true + builder.environments.ssr!.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!) - if (builder.environments.ssr?.config.build.rollupOptions.input) { - builder.environments.ssr!.config.build.write = true - await builder.build(builder.environments.ssr!) - } + await builder.build(builder.environments.ssr!) }, }, } @@ -665,7 +664,8 @@ export default function vitePluginRsc( return }, writeBundle() { - // TODO: this doesn't happen when no-ssr build + // 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. From e309912494837a474b81943a7e1a2c62f74e974c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:57:48 +0900 Subject: [PATCH 09/14] test: more fix `waitForHydration` --- packages/plugin-rsc/e2e/helper.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index 1a4c4485a..dc0797a25 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -10,11 +10,10 @@ export async function waitForHydration(page: Page) { () => page .locator('body > *') - .nth(0) - .evaluate( - (el) => - el && + .evaluateAll((elements) => + elements.some((el) => Object.keys(el).some((key) => key.startsWith('__reactFiber')), + ), ), { timeout: 3000 }, ) From 6fd98dfe340f49c093181e889a2cd0a5b3145b9e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 12:59:06 +0900 Subject: [PATCH 10/14] chore: cleanup --- packages/plugin-rsc/e2e/starter.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index 6778403a2..06826ddc5 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -33,7 +33,6 @@ test.describe('build-no-ssr', () => { }) function defineTest(f: Fixture, variant?: 'no-ssr') { - f.root.includes('no-ssr') test('basic', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) From d878e2a202143f56fbb9028abf7fe7f7b44d3952 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 13:13:56 +0900 Subject: [PATCH 11/14] test: tweak hydration mismatch test --- packages/plugin-rsc/e2e/basic.test.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index cc6cadd06..f6aa9ca1b 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -994,16 +994,17 @@ function defineTest(f: Fixture) { errors.push(error) }) await page.goto(f.url('/?test-hydration-mismatch')) - await waitForHydration(page) - expect(errors).toMatchObject([ - { - message: expect.stringContaining( - f.mode === 'dev' - ? `Hydration failed because the server rendered HTML didn't match the client.` - : `Minified React error #418`, - ), - }, - ]) + await expect(() => { + expect(errors).toMatchObject([ + { + message: expect.stringContaining( + f.mode === 'dev' + ? `Hydration failed because the server rendered HTML didn't match the client.` + : `Minified React error #418`, + ), + }, + ]) + }).toPass() errors.length = 0 await page.goto(f.url()) From 806abda53d08c276a4e7c130af4ca5656688982a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 13:21:54 +0900 Subject: [PATCH 12/14] test: test no ssr build --- packages/plugin-rsc/e2e/starter.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index 06826ddc5..d67f3eee6 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test' import { type Fixture, useFixture } from './fixture' import { expectNoReload, testNoJs, 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' }) @@ -30,6 +32,10 @@ test.describe('dev-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') { From 6c63dba361cf2f9f122192b81f845faeb8f73508 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 13:25:55 +0900 Subject: [PATCH 13/14] test: tweak waitForHydration --- packages/plugin-rsc/e2e/helper.ts | 10 +++++----- packages/plugin-rsc/e2e/starter.test.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts index dc0797a25..b1b167e38 100644 --- a/packages/plugin-rsc/e2e/helper.ts +++ b/packages/plugin-rsc/e2e/helper.ts @@ -4,16 +4,16 @@ 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 > *') - .evaluateAll((elements) => - elements.some((el) => + .locator(locator) + .evaluate( + (el) => + el && Object.keys(el).some((key) => key.startsWith('__reactFiber')), - ), ), { timeout: 3000 }, ) diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index d67f3eee6..1ee2c2e0e 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -1,6 +1,10 @@ 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' @@ -39,6 +43,9 @@ test.describe('build-no-ssr', () => { }) 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) From 1c5f980787964679946f9e97754d4a66c96a08ad Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 12 Jul 2025 13:27:59 +0900 Subject: [PATCH 14/14] chore: cleanup --- packages/plugin-rsc/e2e/basic.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index f6aa9ca1b..cc6cadd06 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -994,17 +994,16 @@ function defineTest(f: Fixture) { errors.push(error) }) await page.goto(f.url('/?test-hydration-mismatch')) - await expect(() => { - expect(errors).toMatchObject([ - { - message: expect.stringContaining( - f.mode === 'dev' - ? `Hydration failed because the server rendered HTML didn't match the client.` - : `Minified React error #418`, - ), - }, - ]) - }).toPass() + await waitForHydration(page) + expect(errors).toMatchObject([ + { + message: expect.stringContaining( + f.mode === 'dev' + ? `Hydration failed because the server rendered HTML didn't match the client.` + : `Minified React error #418`, + ), + }, + ]) errors.length = 0 await page.goto(f.url())