From d9d6c63b06db5d39e07d7ec2a28e48589981baa7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 15:43:12 +0900 Subject: [PATCH 1/6] fix: use async inline script --- packages/plugin-react-oxc/src/index.ts | 2 +- packages/plugin-react-swc/src/index.ts | 2 +- packages/plugin-react/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-react-oxc/src/index.ts b/packages/plugin-react-oxc/src/index.ts index 377d4c04..216d5efa 100644 --- a/packages/plugin-react-oxc/src/index.ts +++ b/packages/plugin-react-oxc/src/index.ts @@ -140,7 +140,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { return [ { tag: 'script', - attrs: { type: 'module' }, + attrs: { type: 'module', async: 'true' }, children: getPreambleCode(config.server!.config.base), }, ] diff --git a/packages/plugin-react-swc/src/index.ts b/packages/plugin-react-swc/src/index.ts index 8e197588..4c430a1a 100644 --- a/packages/plugin-react-swc/src/index.ts +++ b/packages/plugin-react-swc/src/index.ts @@ -135,7 +135,7 @@ const react = (_options?: Options): PluginOption[] => { transformIndexHtml: (_, config) => [ { tag: 'script', - attrs: { type: 'module' }, + attrs: { type: 'module', async: 'true' }, children: getPreambleCode(config.server!.config.base), }, ], diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index cbaa1604..49caded1 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -329,7 +329,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { return [ { tag: 'script', - attrs: { type: 'module' }, + attrs: { type: 'module', async: 'true' }, children: getPreambleCode(config.server!.config.base), }, ] From d44e3b18d2176a3ad02fc5b071946e3529c0e57b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 18:33:20 +0900 Subject: [PATCH 2/6] test: add ssr-react-streaming --- .../__tests__/ssr-react-streaming.spec.ts | 29 +++++ playground/ssr-react-streaming/package.json | 19 +++ .../ssr-react-streaming/src/entry-client.tsx | 8 ++ .../ssr-react-streaming/src/entry-server.tsx | 15 +++ playground/ssr-react-streaming/src/root.tsx | 60 +++++++++ playground/ssr-react-streaming/tsconfig.json | 14 ++ playground/ssr-react-streaming/vite.config.ts | 122 ++++++++++++++++++ pnpm-lock.yaml | 19 +++ 8 files changed, 286 insertions(+) create mode 100644 playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts create mode 100644 playground/ssr-react-streaming/package.json create mode 100644 playground/ssr-react-streaming/src/entry-client.tsx create mode 100644 playground/ssr-react-streaming/src/entry-server.tsx create mode 100644 playground/ssr-react-streaming/src/root.tsx create mode 100644 playground/ssr-react-streaming/tsconfig.json create mode 100644 playground/ssr-react-streaming/vite.config.ts 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..171a9c8e --- /dev/null +++ b/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts @@ -0,0 +1,29 @@ +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' }) + 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 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..fe4bab3d --- /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: {} }) { + 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..8aee2ed8 --- /dev/null +++ b/playground/ssr-react-streaming/vite.config.ts @@ -0,0 +1,122 @@ +import path from 'node:path' +import { defineConfig, Manifest } from 'vite' +import react from '@vitejs/plugin-react' +import fs from 'node:fs' + +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( + path.join(import.meta.dirname, 'dist/server/index.js') + ) + 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': From 45ea549db3f0a820cc0d111f45b8edbcedab74c6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 18:41:20 +0900 Subject: [PATCH 3/6] Revert "fix: use async inline script" This reverts commit d9d6c63b06db5d39e07d7ec2a28e48589981baa7. --- packages/plugin-react-oxc/src/index.ts | 2 +- packages/plugin-react-swc/src/index.ts | 2 +- packages/plugin-react/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-react-oxc/src/index.ts b/packages/plugin-react-oxc/src/index.ts index 216d5efa..377d4c04 100644 --- a/packages/plugin-react-oxc/src/index.ts +++ b/packages/plugin-react-oxc/src/index.ts @@ -140,7 +140,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { return [ { tag: 'script', - attrs: { type: 'module', async: 'true' }, + attrs: { type: 'module' }, children: getPreambleCode(config.server!.config.base), }, ] diff --git a/packages/plugin-react-swc/src/index.ts b/packages/plugin-react-swc/src/index.ts index 4c430a1a..8e197588 100644 --- a/packages/plugin-react-swc/src/index.ts +++ b/packages/plugin-react-swc/src/index.ts @@ -135,7 +135,7 @@ const react = (_options?: Options): PluginOption[] => { transformIndexHtml: (_, config) => [ { tag: 'script', - attrs: { type: 'module', async: 'true' }, + attrs: { type: 'module' }, children: getPreambleCode(config.server!.config.base), }, ], diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index 49caded1..cbaa1604 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -329,7 +329,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { return [ { tag: 'script', - attrs: { type: 'module', async: 'true' }, + attrs: { type: 'module' }, children: getPreambleCode(config.server!.config.base), }, ] From df9b874f32ce23ff53f86b1a4d2ad951ad4857d4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 18:43:05 +0900 Subject: [PATCH 4/6] chore: tweak --- .../ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts b/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts index 171a9c8e..6183dbe4 100644 --- a/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts +++ b/playground/ssr-react-streaming/__tests__/ssr-react-streaming.spec.ts @@ -2,7 +2,7 @@ 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' }) + await page.goto(url, { waitUntil: 'commit' }) // don't wait for full html await expect .poll(() => page.getByTestId('hydrated').textContent()) .toContain('[hydrated: 1]') @@ -15,6 +15,7 @@ test('interactive before suspense is resolved', async () => { }) test.skipIf(isBuild)('hmr', async () => { + await page.goto(url) await expect .poll(() => page.getByTestId('hydrated').textContent()) .toContain('[hydrated: 1]') From e361c4a6d36b01d5a78d1423b87e9e4ccb7c009d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 19:26:49 +0900 Subject: [PATCH 5/6] chore: lint --- playground/ssr-react-streaming/src/root.tsx | 2 +- playground/ssr-react-streaming/vite.config.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/playground/ssr-react-streaming/src/root.tsx b/playground/ssr-react-streaming/src/root.tsx index fe4bab3d..17ee088b 100644 --- a/playground/ssr-react-streaming/src/root.tsx +++ b/playground/ssr-react-streaming/src/root.tsx @@ -48,7 +48,7 @@ function TestSuspense() { // use weak map to suspend for each server render const sleepPromiseMap = new WeakMap>() -function Sleep(props: { context: {} }) { +function Sleep(props: { context: object }) { if (typeof document !== 'undefined') { return
suspense-resolved
} diff --git a/playground/ssr-react-streaming/vite.config.ts b/playground/ssr-react-streaming/vite.config.ts index 8aee2ed8..d6388d9d 100644 --- a/playground/ssr-react-streaming/vite.config.ts +++ b/playground/ssr-react-streaming/vite.config.ts @@ -1,7 +1,8 @@ import path from 'node:path' -import { defineConfig, Manifest } from 'vite' -import react from '@vitejs/plugin-react' 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') From 087c163c9133184d3c47f824a42f6ba5a406d0f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Apr 2025 19:29:59 +0900 Subject: [PATCH 6/6] test: handle windows path --- playground/ssr-react-streaming/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/ssr-react-streaming/vite.config.ts b/playground/ssr-react-streaming/vite.config.ts index d6388d9d..a5100dd5 100644 --- a/playground/ssr-react-streaming/vite.config.ts +++ b/playground/ssr-react-streaming/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ }, async configurePreviewServer(server) { const mod = await import( - path.join(import.meta.dirname, 'dist/server/index.js') + new URL('dist/server/index.js', import.meta.url).toString() ) return () => { server.middlewares.use(async (req, res, next) => {