Skip to content

Commit d44e3b1

Browse files
committed
test: add ssr-react-streaming
1 parent d9d6c63 commit d44e3b1

File tree

8 files changed

+286
-0
lines changed

8 files changed

+286
-0
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect, test } from 'vitest'
2+
import { editFile, isBuild, page, viteTestUrl as url } from '~utils'
3+
4+
test('interactive before suspense is resolved', async () => {
5+
await page.goto(url, { waitUntil: 'commit' })
6+
await expect
7+
.poll(() => page.getByTestId('hydrated').textContent())
8+
.toContain('[hydrated: 1]')
9+
await expect
10+
.poll(() => page.getByTestId('suspense').textContent())
11+
.toContain('suspense-fallback')
12+
await expect
13+
.poll(() => page.getByTestId('suspense').textContent(), { timeout: 2000 })
14+
.toContain('suspense-resolved')
15+
})
16+
17+
test.skipIf(isBuild)('hmr', async () => {
18+
await expect
19+
.poll(() => page.getByTestId('hydrated').textContent())
20+
.toContain('[hydrated: 1]')
21+
await page.getByTestId('counter').click()
22+
await expect
23+
.poll(() => page.getByTestId('counter').textContent())
24+
.toContain('Counter: 1')
25+
editFile('src/root.tsx', (code) => code.replace('Counter:', 'Counter-edit:'))
26+
await expect
27+
.poll(() => page.getByTestId('counter').textContent())
28+
.toContain('Counter-edit: 1')
29+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "@vitejs/test-ssr-react",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite dev",
7+
"build": "vite build --app",
8+
"preview": "vite preview"
9+
},
10+
"dependencies": {
11+
"react": "^19.1.0",
12+
"react-dom": "^19.1.0"
13+
},
14+
"devDependencies": {
15+
"@types/react": "^19.1.2",
16+
"@types/react-dom": "^19.1.2",
17+
"@vitejs/plugin-react": "workspace:*"
18+
}
19+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import ReactDOMClient from 'react-dom/client'
2+
import { Root } from './root'
3+
4+
function main() {
5+
ReactDOMClient.hydrateRoot(document, <Root />)
6+
}
7+
8+
main()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { IncomingMessage, OutgoingMessage } from 'node:http'
2+
import ReactDOMServer from 'react-dom/server'
3+
import { Root } from './root'
4+
5+
export default async function handler(
6+
_req: IncomingMessage,
7+
res: OutgoingMessage,
8+
) {
9+
const assets = await import('virtual:assets-manifest' as any)
10+
const htmlStream = ReactDOMServer.renderToPipeableStream(<Root />, {
11+
bootstrapModules: assets.default.bootstrapModules,
12+
})
13+
res.setHeader('content-type', 'text/html;charset=utf-8')
14+
htmlStream.pipe(res)
15+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as React from 'react'
2+
3+
export function Root() {
4+
return (
5+
<html>
6+
<head>
7+
<title>Streaming</title>
8+
</head>
9+
<body>
10+
<h4>Streaming</h4>
11+
<TestSuspense />
12+
<Hydrated />
13+
<Counter />
14+
</body>
15+
</html>
16+
)
17+
}
18+
19+
function Counter() {
20+
const [count, setCount] = React.useState(0)
21+
return (
22+
<button data-testid="counter" onClick={() => setCount((c) => c + 1)}>
23+
Counter: {count}
24+
</button>
25+
)
26+
}
27+
28+
function Hydrated() {
29+
const hydrated = React.useSyncExternalStore(
30+
React.useCallback(() => () => {}, []),
31+
() => true,
32+
() => false,
33+
)
34+
return <div data-testid="hydrated">[hydrated: {hydrated ? 1 : 0}]</div>
35+
}
36+
37+
function TestSuspense() {
38+
const context = React.useState(() => ({}))[0]
39+
return (
40+
<div data-testid="suspense">
41+
<React.Suspense fallback={<div>suspense-fallback</div>}>
42+
<Sleep context={context} />
43+
</React.Suspense>
44+
</div>
45+
)
46+
}
47+
48+
// use weak map to suspend for each server render
49+
const sleepPromiseMap = new WeakMap<object, Promise<void>>()
50+
51+
function Sleep(props: { context: {} }) {
52+
if (typeof document !== 'undefined') {
53+
return <div>suspense-resolved</div>
54+
}
55+
if (!sleepPromiseMap.has(props.context)) {
56+
sleepPromiseMap.set(props.context, new Promise((r) => setTimeout(r, 1000)))
57+
}
58+
React.use(sleepPromiseMap.get(props.context))
59+
return <div>suspense-resolved</div>
60+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"skipLibCheck": true,
7+
"noEmit": true,
8+
"jsx": "react-jsx",
9+
"types": ["vite/client"],
10+
"paths": {
11+
"~utils": ["../test-utils.ts"]
12+
}
13+
}
14+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import path from 'node:path'
2+
import { defineConfig, Manifest } from 'vite'
3+
import react from '@vitejs/plugin-react'
4+
import fs from 'node:fs'
5+
6+
const CLIENT_ENTRY = path.join(import.meta.dirname, 'src/entry-client.jsx')
7+
const SERVER_ENTRY = path.join(import.meta.dirname, 'src/entry-server.jsx')
8+
9+
export default defineConfig({
10+
appType: 'custom',
11+
build: {
12+
minify: false,
13+
},
14+
environments: {
15+
client: {
16+
build: {
17+
manifest: true,
18+
outDir: 'dist/client',
19+
rollupOptions: {
20+
input: { index: CLIENT_ENTRY },
21+
},
22+
},
23+
},
24+
ssr: {
25+
build: {
26+
outDir: 'dist/server',
27+
rollupOptions: {
28+
input: { index: SERVER_ENTRY },
29+
},
30+
},
31+
},
32+
},
33+
plugins: [
34+
react(),
35+
{
36+
name: 'ssr-middleware',
37+
configureServer(server) {
38+
return () => {
39+
server.middlewares.use(async (req, res, next) => {
40+
try {
41+
const mod = await server.ssrLoadModule(SERVER_ENTRY)
42+
await mod.default(req, res)
43+
} catch (e) {
44+
next(e)
45+
}
46+
})
47+
}
48+
},
49+
async configurePreviewServer(server) {
50+
const mod = await import(
51+
path.join(import.meta.dirname, 'dist/server/index.js')
52+
)
53+
return () => {
54+
server.middlewares.use(async (req, res, next) => {
55+
try {
56+
await mod.default(req, res)
57+
} catch (e) {
58+
next(e)
59+
}
60+
})
61+
}
62+
},
63+
},
64+
{
65+
name: 'virtual-browser-entry',
66+
resolveId(source) {
67+
if (source === 'virtual:browser-entry') {
68+
return '\0' + source
69+
}
70+
},
71+
load(id) {
72+
if (id === '\0virtual:browser-entry') {
73+
if (this.environment.mode === 'dev') {
74+
// ensure react hmr global before running client entry on dev.
75+
// vite prepends base via import analysis, so we only need `/@react-refresh`.
76+
return (
77+
react.preambleCode.replace('__BASE__', '/') +
78+
`import(${JSON.stringify(CLIENT_ENTRY)})`
79+
)
80+
}
81+
}
82+
},
83+
},
84+
{
85+
name: 'virtual-assets-manifest',
86+
resolveId(source) {
87+
if (source === 'virtual:assets-manifest') {
88+
return '\0' + source
89+
}
90+
},
91+
load(id) {
92+
if (id === '\0virtual:assets-manifest') {
93+
let bootstrapModules: string[] = []
94+
if (this.environment.mode === 'dev') {
95+
bootstrapModules = ['/@id/__x00__virtual:browser-entry']
96+
} else {
97+
const manifest: Manifest = JSON.parse(
98+
fs.readFileSync(
99+
path.join(
100+
import.meta.dirname,
101+
'dist/client/.vite/manifest.json',
102+
),
103+
'utf-8',
104+
),
105+
)
106+
const entry = Object.values(manifest).find(
107+
(v) => v.name === 'index' && v.isEntry,
108+
)!
109+
bootstrapModules = [`/${entry.file}`]
110+
}
111+
return `export default ${JSON.stringify({ bootstrapModules })}`
112+
}
113+
},
114+
},
115+
],
116+
builder: {
117+
async buildApp(builder) {
118+
await builder.build(builder.environments.client)
119+
await builder.build(builder.environments.ssr)
120+
},
121+
},
122+
})

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)