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 00000000..6183dbe4
--- /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 00000000..eb446a95
--- /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 00000000..62613f89
--- /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 00000000..807370e4
--- /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 00000000..17ee088b
--- /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
+
+
+
+
+
+ )
+}
+
+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