diff --git a/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts b/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts
new file mode 100644
index 000000000..6183dbe47
--- /dev/null
+++ b/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from 'vitest'
+import { editFile, isBuild, page, viteTestUrl as url } from '~utils'
+
+test('interactive before suspense is resolved', async () => {
+ await page.goto(url, { waitUntil: 'commit' }) // don't wait for full html
+ await expect
+ .poll(() => page.getByTestId('hydrated').textContent())
+ .toContain('[hydrated: 1]')
+ await expect
+ .poll(() => page.getByTestId('suspense').textContent())
+ .toContain('suspense-fallback')
+ await expect
+ .poll(() => page.getByTestId('suspense').textContent(), { timeout: 2000 })
+ .toContain('suspense-resolved')
+})
+
+test.skipIf(isBuild)('hmr', async () => {
+ await page.goto(url)
+ await expect
+ .poll(() => page.getByTestId('hydrated').textContent())
+ .toContain('[hydrated: 1]')
+ await page.getByTestId('counter').click()
+ await expect
+ .poll(() => page.getByTestId('counter').textContent())
+ .toContain('Counter: 1')
+ editFile('src/root.tsx', (code) => code.replace('Counter:', 'Counter-edit:'))
+ await expect
+ .poll(() => page.getByTestId('counter').textContent())
+ .toContain('Counter-edit: 1')
+})
diff --git a/playground/ssr-react-streaming/package.json b/playground/ssr-react-streaming/package.json
new file mode 100644
index 000000000..eb446a950
--- /dev/null
+++ b/playground/ssr-react-streaming/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@vitejs/test-ssr-react",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build --app",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@vitejs/plugin-react": "workspace:*"
+ }
+}
diff --git a/playground/ssr-react-streaming/src/entry-client.tsx b/playground/ssr-react-streaming/src/entry-client.tsx
new file mode 100644
index 000000000..62613f894
--- /dev/null
+++ b/playground/ssr-react-streaming/src/entry-client.tsx
@@ -0,0 +1,8 @@
+import ReactDOMClient from 'react-dom/client'
+import { Root } from './root'
+
+function main() {
+ ReactDOMClient.hydrateRoot(document, )
+}
+
+main()
diff --git a/playground/ssr-react-streaming/src/entry-server.tsx b/playground/ssr-react-streaming/src/entry-server.tsx
new file mode 100644
index 000000000..807370e46
--- /dev/null
+++ b/playground/ssr-react-streaming/src/entry-server.tsx
@@ -0,0 +1,15 @@
+import type { IncomingMessage, OutgoingMessage } from 'node:http'
+import ReactDOMServer from 'react-dom/server'
+import { Root } from './root'
+
+export default async function handler(
+ _req: IncomingMessage,
+ res: OutgoingMessage,
+) {
+ const assets = await import('virtual:assets-manifest' as any)
+ const htmlStream = ReactDOMServer.renderToPipeableStream(, {
+ bootstrapModules: assets.default.bootstrapModules,
+ })
+ res.setHeader('content-type', 'text/html;charset=utf-8')
+ htmlStream.pipe(res)
+}
diff --git a/playground/ssr-react-streaming/src/root.tsx b/playground/ssr-react-streaming/src/root.tsx
new file mode 100644
index 000000000..17ee088b5
--- /dev/null
+++ b/playground/ssr-react-streaming/src/root.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+
+export function Root() {
+ return (
+
+
+ Streaming
+
+ Streaming
+ Streaming
+
+
+
+
+
+ )
+}
+
+function Counter() {
+ const [count, setCount] = React.useState(0)
+ return (
+
+ )
+}
+
+function Hydrated() {
+ const hydrated = React.useSyncExternalStore(
+ React.useCallback(() => () => {}, []),
+ () => true,
+ () => false,
+ )
+ return [hydrated: {hydrated ? 1 : 0}]
+}
+
+function TestSuspense() {
+ const context = React.useState(() => ({}))[0]
+ return (
+
+ suspense-fallback
}>
+
+
+
+ )
+}
+
+// use weak map to suspend for each server render
+const sleepPromiseMap = new WeakMap>()
+
+function Sleep(props: { context: object }) {
+ if (typeof document !== 'undefined') {
+ return suspense-resolved
+ }
+ if (!sleepPromiseMap.has(props.context)) {
+ sleepPromiseMap.set(props.context, new Promise((r) => setTimeout(r, 1000)))
+ }
+ React.use(sleepPromiseMap.get(props.context))
+ return suspense-resolved
+}
diff --git a/playground/ssr-react-streaming/tsconfig.json b/playground/ssr-react-streaming/tsconfig.json
new file mode 100644
index 000000000..1f2027627
--- /dev/null
+++ b/playground/ssr-react-streaming/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client"],
+ "paths": {
+ "~utils": ["../test-utils.ts"]
+ }
+ }
+}
diff --git a/playground/ssr-react-streaming/vite.config.ts b/playground/ssr-react-streaming/vite.config.ts
new file mode 100644
index 000000000..a5100dd5c
--- /dev/null
+++ b/playground/ssr-react-streaming/vite.config.ts
@@ -0,0 +1,123 @@
+import path from 'node:path'
+import fs from 'node:fs'
+import type { Manifest } from 'vite'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+const CLIENT_ENTRY = path.join(import.meta.dirname, 'src/entry-client.jsx')
+const SERVER_ENTRY = path.join(import.meta.dirname, 'src/entry-server.jsx')
+
+export default defineConfig({
+ appType: 'custom',
+ build: {
+ minify: false,
+ },
+ environments: {
+ client: {
+ build: {
+ manifest: true,
+ outDir: 'dist/client',
+ rollupOptions: {
+ input: { index: CLIENT_ENTRY },
+ },
+ },
+ },
+ ssr: {
+ build: {
+ outDir: 'dist/server',
+ rollupOptions: {
+ input: { index: SERVER_ENTRY },
+ },
+ },
+ },
+ },
+ plugins: [
+ react(),
+ {
+ name: 'ssr-middleware',
+ configureServer(server) {
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ try {
+ const mod = await server.ssrLoadModule(SERVER_ENTRY)
+ await mod.default(req, res)
+ } catch (e) {
+ next(e)
+ }
+ })
+ }
+ },
+ async configurePreviewServer(server) {
+ const mod = await import(
+ new URL('dist/server/index.js', import.meta.url).toString()
+ )
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ try {
+ await mod.default(req, res)
+ } catch (e) {
+ next(e)
+ }
+ })
+ }
+ },
+ },
+ {
+ name: 'virtual-browser-entry',
+ resolveId(source) {
+ if (source === 'virtual:browser-entry') {
+ return '0円' + source
+ }
+ },
+ load(id) {
+ if (id === '0円virtual:browser-entry') {
+ if (this.environment.mode === 'dev') {
+ // ensure react hmr global before running client entry on dev.
+ // vite prepends base via import analysis, so we only need `/@react-refresh`.
+ return (
+ react.preambleCode.replace('__BASE__', '/') +
+ `import(${JSON.stringify(CLIENT_ENTRY)})`
+ )
+ }
+ }
+ },
+ },
+ {
+ name: 'virtual-assets-manifest',
+ resolveId(source) {
+ if (source === 'virtual:assets-manifest') {
+ return '0円' + source
+ }
+ },
+ load(id) {
+ if (id === '0円virtual:assets-manifest') {
+ let bootstrapModules: string[] = []
+ if (this.environment.mode === 'dev') {
+ bootstrapModules = ['/@id/__x00__virtual:browser-entry']
+ } else {
+ const manifest: Manifest = JSON.parse(
+ fs.readFileSync(
+ path.join(
+ import.meta.dirname,
+ 'dist/client/.vite/manifest.json',
+ ),
+ 'utf-8',
+ ),
+ )
+ const entry = Object.values(manifest).find(
+ (v) => v.name === 'index' && v.isEntry,
+ )!
+ bootstrapModules = [`/${entry.file}`]
+ }
+ return `export default ${JSON.stringify({ bootstrapModules })}`
+ }
+ },
+ },
+ ],
+ builder: {
+ async buildApp(builder) {
+ await builder.build(builder.environments.client)
+ await builder.build(builder.environments.ssr)
+ },
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b62207291..5e56f0486 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -631,6 +631,25 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-react
+ playground/ssr-react-streaming:
+ dependencies:
+ 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.2
+ version: 19.1.2
+ '@types/react-dom':
+ specifier: ^19.1.2
+ version: 19.1.2(@types/react@19.1.2)
+ '@vitejs/plugin-react':
+ specifier: workspace:*
+ version: link:../../packages/plugin-react
+
packages:
'@aashutoshrathi/word-wrap@1.2.6':