diff --git a/packages/plugin-rsc/e2e/browser-mode.test.ts b/packages/plugin-rsc/e2e/browser-mode.test.ts index 42e2e6d6a..84280fd14 100644 --- a/packages/plugin-rsc/e2e/browser-mode.test.ts +++ b/packages/plugin-rsc/e2e/browser-mode.test.ts @@ -2,14 +2,23 @@ import { expect, test, type Page } from '@playwright/test' import { useFixture } from './fixture' import { defineStarterTest } from './starter' -test.describe('dev-browser-mode', () => { - // Webkit fails by - // > TypeError: ReadableByteStreamController is not implemented - test.skip(({ browserName }) => browserName === 'webkit') +// Webkit fails by +// > TypeError: ReadableByteStreamController is not implemented +test.skip(({ browserName }) => browserName === 'webkit') +test.describe('dev-browser-mode', () => { const f = useFixture({ root: 'examples/browser-mode', mode: 'dev' }) defineStarterTest(f, 'browser-mode') + defineBrowserModeTest(f) +}) +test.describe('build-browser-mode', () => { + const f = useFixture({ root: 'examples/browser-mode', mode: 'build' }) + defineStarterTest(f, 'browser-mode') + defineBrowserModeTest(f) +}) + +function defineBrowserModeTest(f: ReturnType) { // action-bind tests copied from basic.test.ts test('action bind simple', async ({ page }) => { @@ -71,4 +80,4 @@ test.describe('dev-browser-mode', () => { .getByRole('button', { name: 'test-server-action-bind-reset' }) .click() } -}) +} diff --git a/packages/plugin-rsc/examples/browser-mode/package.json b/packages/plugin-rsc/examples/browser-mode/package.json index 8d1d97f0f..70e538ba2 100644 --- a/packages/plugin-rsc/examples/browser-mode/package.json +++ b/packages/plugin-rsc/examples/browser-mode/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "false && vite build", - "preview": "false && vite preview" + "build": "vite build", + "preview": "vite preview" }, "dependencies": { "react": "^19.1.1", diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx index a8ce1e792..b5a85a034 100644 --- a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx @@ -8,13 +8,24 @@ import { encodeReply, } from '@vitejs/plugin-rsc/react/browser' import type { RscPayload } from './entry.rsc' +import buildClientReferences from 'virtual:vite-rsc-browser-mode/build-client-references' let fetchServer: typeof import('./entry.rsc').fetchServer export function initialize(options: { fetchServer: typeof fetchServer }) { fetchServer = options.fetchServer setRequireModule({ - load: (id) => import(/* @vite-ignore */ id), + load: (id) => { + if (import.meta.env.__vite_rsc_build__) { + const import_ = buildClientReferences[id] + if (!import_) { + throw new Error(`invalid client reference: ${id}`) + } + return import_() + } else { + return import(/* @vite-ignore */ id) + } + }, }) } diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx index 44adc4bac..3cbc83a99 100644 --- a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx @@ -10,6 +10,7 @@ import { import type React from 'react' import { Root } from '../root' import type { ReactFormState } from 'react-dom/client' +import buildServerReferences from 'virtual:vite-rsc-browser-mode/build-server-references' export type RscPayload = { root: React.ReactNode @@ -20,7 +21,19 @@ export type RscPayload = { declare let __vite_rsc_raw_import__: (id: string) => Promise export function initialize() { - setRequireModule({ load: (id) => __vite_rsc_raw_import__(id) }) + setRequireModule({ + load: (id) => { + if (import.meta.env.__vite_rsc_build__) { + const import_ = buildServerReferences[id] + if (!import_) { + throw new Error(`invalid server reference: ${id}`) + } + return import_() + } else { + return __vite_rsc_raw_import__(/* @vite-ignore */ id) + } + }, + }) } export async function fetchServer(request: Request): Promise { diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx new file mode 100644 index 000000000..8ca40ae43 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx @@ -0,0 +1,25 @@ +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +export default async function loadClient() { + const runner = new ModuleRunner( + { + sourcemapInterceptor: false, + transport: { + invoke: async (payload) => { + const response = await fetch( + '/@vite/invoke-react-client?' + + new URLSearchParams({ + data: JSON.stringify(payload), + }), + ) + return response.json() + }, + }, + hmr: false, + }, + new ESModulesEvaluator(), + ) + return await runner.import( + '/src/framework/entry.browser.tsx', + ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx index 12d5c7546..6a485deb4 100644 --- a/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx @@ -1,35 +1,11 @@ -import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' import * as server from './entry.rsc' +import loadClient from 'virtual:vite-rsc-browser-mode/load-client' async function main() { - const client = await importClient() + const client = await loadClient() server.initialize() client.initialize({ fetchServer: server.fetchServer }) await client.main() } -async function importClient() { - const runner = new ModuleRunner( - { - sourcemapInterceptor: false, - transport: { - invoke: async (payload) => { - const response = await fetch( - '/@vite/invoke-react-client?' + - new URLSearchParams({ - data: JSON.stringify(payload), - }), - ) - return response.json() - }, - }, - hmr: false, - }, - new ESModulesEvaluator(), - ) - return await runner.import( - '/src/framework/entry.browser.tsx', - ) -} - main() diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts new file mode 100644 index 000000000..74f7cc59d --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts @@ -0,0 +1,14 @@ +declare module 'virtual:vite-rsc-browser-mode/build-client-references' { + const default_: Record Promise> + export default default_ +} + +declare module 'virtual:vite-rsc-browser-mode/build-server-references' { + const default_: Record Promise> + export default default_ +} + +declare module 'virtual:vite-rsc-browser-mode/load-client' { + const default_: () => Promise + export default default_ +} diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts index c8bf161f4..b03059904 100644 --- a/packages/plugin-rsc/examples/browser-mode/vite.config.ts +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -1,50 +1,44 @@ -import { defaultClientConditions, defineConfig } from 'vite' -import { vitePluginRscMinimal } from '@vitejs/plugin-rsc/plugin' +import { defaultClientConditions, defineConfig, type Plugin } from 'vite' +import { + vitePluginRscMinimal, + getPluginApi, + type PluginApi, +} from '@vitejs/plugin-rsc/plugin' // import inspect from 'vite-plugin-inspect' export default defineConfig({ plugins: [ // inspect(), - vitePluginRscMinimal({ + rscBrowserModePlugin(), + ], + environments: { + client: { + build: { + minify: false, + }, + }, + }, +}) + +function rscBrowserModePlugin(): Plugin[] { + let manager: PluginApi['manager'] + + return [ + ...vitePluginRscMinimal({ environment: { rsc: 'client', browser: 'react_client', }, }), { - name: 'rsc:browser-mode', - configureServer(server) { - server.middlewares.use(async (req, res, next) => { - const url = new URL(req.url ?? '/', 'https://any.local') - if (url.pathname === '/@vite/invoke-react-client') { - const payload = JSON.parse(url.searchParams.get('data')!) - const result = - await server.environments['react_client']!.hot.handleInvoke( - payload, - ) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(result)) - return - } - next() - }) - }, - // for "react_client" hmr, it requires: - // - enable fast-refresh transform on `react_client` environment - // - currently `@vitejs/plugin-react` doesn't support it - // - implement and enable module runner hmr - hotUpdate(ctx) { - if (this.environment.name === 'react_client') { - if (ctx.modules.length > 0) { - ctx.server.environments.client.hot.send({ - type: 'full-reload', - path: ctx.file, - }) - } - } - }, - config() { + name: 'rsc-browser-mode', + config(userConfig, env) { return { + define: { + 'import.meta.env.__vite_rsc_build__': JSON.stringify( + env.command === 'build', + ), + }, environments: { client: { keepProcessEnv: false, @@ -60,12 +54,12 @@ export default defineConfig({ 'react/jsx-dev-runtime', '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge', '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge', - // TODO: browser build breaks `src/actin-bind` examples - // '@vitejs/plugin-rsc/vendor/react-server-dom/server.browser', - // '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser', ], exclude: ['vite', '@vitejs/plugin-rsc'], }, + build: { + outDir: 'dist/client', + }, }, react_client: { keepProcessEnv: false, @@ -87,18 +81,155 @@ export default defineConfig({ platform: 'browser', }, }, + build: { + outDir: 'dist/react_client', + copyPublicDir: false, + emitAssets: true, + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, }, }, - resolve: { - // alias: { - // '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge': - // '@vitejs/plugin-rsc/vendor/react-server-dom/server.browser', - // '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge': - // '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser', - // }, + builder: { + sharedPlugins: true, + sharedConfigBuild: true, + }, + build: { + // packages/common/warning.ts + rollupOptions: { + onwarn(warning, defaultHandler) { + if ( + warning.code === 'MODULE_LEVEL_DIRECTIVE' && + (warning.message.includes('use client') || + warning.message.includes('use server')) + ) { + return + } + // https://github.com/vitejs/vite/issues/15012 + if ( + warning.code === 'SOURCEMAP_ERROR' && + warning.message.includes('resolve original location') && + warning.pos === 0 + ) { + return + } + if (userConfig.build?.rollupOptions?.onwarn) { + userConfig.build.rollupOptions.onwarn(warning, defaultHandler) + } else { + defaultHandler(warning) + } + }, + }, }, } }, + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? '/', 'https://any.local') + if (url.pathname === '/@vite/invoke-react-client') { + const payload = JSON.parse(url.searchParams.get('data')!) + const result = + await server.environments['react_client']!.hot.handleInvoke( + payload, + ) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + return + } + next() + }) + }, + hotUpdate(ctx) { + if (this.environment.name === 'react_client') { + if (ctx.modules.length > 0) { + ctx.server.environments.client.hot.send({ + type: 'full-reload', + path: ctx.file, + }) + } + } + }, + async buildApp(builder) { + const reactServer = builder.environments.client! + const reactClient = builder.environments['react_client']! + manager.isScanBuild = true + reactServer.config.build.write = false + await builder.build(reactServer) + manager.isScanBuild = false + reactServer.config.build.write = true + await builder.build(reactClient) + await builder.build(reactServer) + }, }, - ], -}) + { + name: 'rsc-browser-mode:load-client', + resolveId(source) { + if (source === 'virtual:vite-rsc-browser-mode/load-client') { + if (this.environment.mode === 'dev') { + return this.resolve('/src/framework/load-client-dev') + } + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/load-client') { + if (manager.isScanBuild) { + return `export default async () => {}` + } else { + return `export default async () => import("/dist/react_client/index.js")` + } + } + }, + }, + { + name: 'rsc-browser-mode:build-client-references', + resolveId(source) { + if ( + source === 'virtual:vite-rsc-browser-mode/build-client-references' + ) { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/build-client-references') { + if (this.environment.mode === 'dev') { + return `export default {}` // no-op during dev + } + let code = '' + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` + } + return `export default {${code}}` + } + }, + }, + { + name: 'rsc-browser-mode:build-server-references', + resolveId(source) { + if ( + source === 'virtual:vite-rsc-browser-mode/build-server-references' + ) { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/build-server-references') { + if (this.environment.mode === 'dev') { + return `export default {}` // no-op during dev + } + let code = '' + for (const meta of Object.values(manager.serverReferenceMetaMap)) { + code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` + } + return `export default {${code}}` + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index 92c432cad..0c4623579 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -1,2 +1,7 @@ -export { default, type RscPluginOptions } from './plugin' +export { + default, + type RscPluginOptions, + getPluginApi, + type PluginApi, +} from './plugin' export { transformHoistInlineDirective } from './transforms' diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 1b3a9c292..d779d4ba5 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -206,6 +206,18 @@ export type RscPluginOptions = { }) => string | undefined } +export type PluginApi = { + manager: RscPluginManager +} + +/** @experimental */ +export function getPluginApi( + config: Pick, +): PluginApi | undefined { + const plugin = config.plugins.find((p) => p.name === 'rsc:minimal') + return plugin?.api as PluginApi | undefined +} + /** @experimental */ export function vitePluginRscMinimal( rscPluginOptions: RscPluginOptions = {}, @@ -215,6 +227,10 @@ export function vitePluginRscMinimal( { name: 'rsc:minimal', enforce: 'pre', + // https://rollupjs.org/plugin-development/#direct-plugin-communication + api: { + manager, + } satisfies PluginApi, async config() { await esModuleLexer.init }, @@ -247,6 +263,7 @@ export function vitePluginRscMinimal( ...vitePluginUseClient(rscPluginOptions, manager), ...vitePluginUseServer(rscPluginOptions, manager), ...vitePluginDefineEncryptionKey(rscPluginOptions), + scanBuildStripPlugin({ manager }), ] } @@ -1494,7 +1511,7 @@ function vitePluginDefineEncryptionKey( }, renderChunk(code, chunk) { if (code.includes(KEY_PLACEHOLDER)) { - assert.equal(this.environment.name, 'rsc') + assert.equal(this.environment.name, serverEnvironmentName) emitEncryptionKey = true const normalizedPath = normalizeRelativePath( path.relative(path.join(chunk.fileName, '..'), KEY_FILE), @@ -1507,7 +1524,10 @@ function vitePluginDefineEncryptionKey( } }, writeBundle() { - if (this.environment.name === 'rsc' && emitEncryptionKey) { + if ( + this.environment.name === serverEnvironmentName && + emitEncryptionKey + ) { fs.writeFileSync( path.join(this.environment.config.build.outDir, KEY_FILE), `export default ${defineEncryptionKey};\n`,