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>() + +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 00000000..1f202762 --- /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 00000000..a5100dd5 --- /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 === '\0virtual: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 === '\0virtual: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 b6220729..5e56f048 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':