diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index ce5fbe72b..bb6fd73e9 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -17,6 +17,13 @@ You can create a starter project by: npm create vite@latest -- --template rsc ``` +### Bundled development + +Vite's experimental `bundledDev` mode supports initial RSC rendering and client +HMR. Changes to the server graph, including client-boundary changes, require a +dev-server restart. Vite does not yet expose an atomic bundledDev +rebuild-and-reload operation that plugins can safely request from `watchChange`. + ## Examples **Start here:** [`./examples/starter`](./examples/starter) - Recommended for understanding the package diff --git a/packages/plugin-rsc/e2e/bundled-dev.test.ts b/packages/plugin-rsc/e2e/bundled-dev.test.ts new file mode 100644 index 000000000..c00abce8c --- /dev/null +++ b/packages/plugin-rsc/e2e/bundled-dev.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { expectNoPageError, expectNoReload, waitForHydration } from './helper' + +test.describe('bundled dev', () => { + const root = 'examples/e2e/temp/bundled-dev' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': { + edit: (source) => + source.replace( + 'export default defineConfig({', + 'export default defineConfig({\n experimental: { bundledDev: true },', + ), + }, + }, + }) + }) + + const fixture = useFixture({ root, mode: 'dev' }) + + test('serves the initial bundled RSC app', async ({ page }) => { + using _ = expectNoPageError(page) + const requests: string[] = [] + page.on('request', (request) => requests.push(request.url())) + + await page.goto(fixture.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'Client Counter: 0' }).click() + + await expect( + page.getByRole('button', { name: 'Client Counter: 1' }), + ).toBeVisible() + await expect(page.locator('.card').first()).toHaveCSS( + 'padding-left', + '16px', + ) + await expect(page.getByAltText('React logo')).not.toHaveJSProperty( + 'naturalWidth', + 0, + ) + expect(requests).toContain(fixture.url('assets/index.js')) + expect(requests).not.toContain( + fixture.url('src/framework/entry.browser.tsx'), + ) + }) + + test('keeps client HMR', async ({ page }) => { + using _errors = expectNoPageError(page) + await page.goto(fixture.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'Client Counter: 0' }).click() + await using _ = await expectNoReload(page) + + const editor = fixture.createEditor('src/client.tsx') + editor.edit((source) => + source.replace('Client Counter', 'Client [edit] Counter'), + ) + await expect( + page.getByRole('button', { name: 'Client [edit] Counter: 1' }), + ).toBeVisible() + + editor.reset() + await expect( + page.getByRole('button', { name: 'Client Counter: 1' }), + ).toBeVisible() + }) +}) diff --git a/packages/plugin-rsc/src/browser.ts b/packages/plugin-rsc/src/browser.ts index 0892a8879..4da4efcc2 100644 --- a/packages/plugin-rsc/src/browser.ts +++ b/packages/plugin-rsc/src/browser.ts @@ -8,7 +8,10 @@ initialize() function initialize(): void { setRequireModule({ load: async (id) => { - if (!import.meta.env.__vite_rsc_build__) { + if ( + !import.meta.env.__vite_rsc_build__ && + !import.meta.env.__vite_rsc_bundled_dev__ + ) { // @ts-ignore return __vite_rsc_raw_import__( withTrailingSlash(import.meta.env.BASE_URL) + id.slice(1), diff --git a/packages/plugin-rsc/src/bundled-dev.ts b/packages/plugin-rsc/src/bundled-dev.ts new file mode 100644 index 000000000..cf8cb9659 --- /dev/null +++ b/packages/plugin-rsc/src/bundled-dev.ts @@ -0,0 +1,128 @@ +import fs from 'node:fs' +import path from 'node:path' +import { type DevEnvironment, isCSSRequest, normalizePath } from 'vite' +import { withResolvedIdProxy } from './plugins/resolved-id-proxy' +import { parseIdQuery } from './plugins/shared' +import { cleanUrl } from './plugins/vite-utils' + +export type BundledDevServerGraph = { + moduleIds: Set + cssImports: Set + assetImports: Set +} + +export async function crawlBundledDevServerGraph( + environment: DevEnvironment, + sources: string[], +): Promise { + const moduleIds = new Set() + const cssImports = new Set() + const assetImports = new Set() + const config = environment.getTopLevelConfig() + + async function crawl(source: string): Promise { + const resolved = await environment.pluginContainer.resolveId(source) + if (!resolved || resolved.external || moduleIds.has(resolved.id)) return + moduleIds.add(resolved.id) + if (resolved.id.includes('virtual:vite-rsc/assets-manifest')) return + + const { filename, query } = parseIdQuery(resolved.id) + let file = fs.existsSync(filename) ? filename : undefined + if (!file && filename.startsWith('/') && config.publicDir) { + const publicFile = path.join(config.publicDir, filename.slice(1)) + if (fs.existsSync(publicFile)) file = publicFile + } + file = file && normalizePath(file) + const hasRawQuery = 'raw' in query + const hasInlineQuery = 'inline' in query + const hasUrlQuery = 'url' in query + const isCss = isCSSRequest(resolved.id) + const isAsset = config.assetsInclude(filename) || hasUrlQuery + const isResource = + isCss || isAsset || hasRawQuery || hasInlineQuery || hasUrlQuery + if (file && isResource) { + if (isCss && !hasRawQuery && !hasInlineQuery && !hasUrlQuery) { + cssImports.add(resolved.id) + } else if (isAsset && !hasRawQuery && !hasInlineQuery) { + assetImports.add(resolved.id) + } + return + } + + const requestId = + environment.moduleGraph.getModuleById(resolved.id)?.url ?? resolved.id + const result = await environment.transformRequest(requestId) + await environment.waitForRequestsIdle() + const module = environment.moduleGraph.getModuleById(resolved.id) + if (!module) return + + for (const imported of module.importedModules) { + await crawl(imported.url) + } + for (const imported of [ + ...(result?.deps ?? []), + ...(result?.dynamicDeps ?? []), + ]) { + await crawl(imported) + } + } + + for (const source of sources) { + await crawl(source) + } + return { + moduleIds, + cssImports, + assetImports, + } +} + +type ClientReference = { + importId: string + referenceKey: string +} + +export function filterBundledDevClientReferences( + references: Record, + moduleIds: Set, +): Record { + const reachableIds = new Set( + [...moduleIds].map((id) => normalizePath(cleanUrl(id))), + ) + return Object.fromEntries( + Object.entries(references).filter(([id]) => + reachableIds.has(normalizePath(cleanUrl(id))), + ), + ) +} + +export function renderBundledDevClientReferences( + graph: BundledDevServerGraph, + references: Record, +): string { + const entries: string[] = [] + let imports = '' + for (const [index, meta] of Object.values(references) + .sort((a, b) => a.referenceKey.localeCompare(b.referenceKey)) + .entries()) { + const name = `__vite_rsc_client_reference_${index}` + imports += `import * as ${name} from ${JSON.stringify(withResolvedIdProxy(meta.importId))};\n` + entries.push( + `${JSON.stringify(meta.referenceKey)}: () => Promise.resolve(${name})`, + ) + } + for (const id of [...graph.cssImports].sort()) { + imports += `import ${JSON.stringify(withResolvedIdProxy(id))};\n` + } + const assets = [...graph.assetImports].sort().map((id, index) => { + const name = `__vite_rsc_server_asset_${index}` + imports += `import ${name} from ${JSON.stringify(withResolvedIdProxy(id))};\n` + return name + }) + if (assets.length > 0) { + entries.push( + `[Symbol.for("vite-rsc:server-assets")]: [${assets.join(', ')}]`, + ) + } + return `${imports}export default {\n${entries.join(',\n')}\n};\n` +} diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 86cbba631..6904feaf7 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -26,6 +26,11 @@ import { parseAstAsync, } from 'vite' import { crawlFrameworkPkgs } from 'vitefu' +import { + crawlBundledDevServerGraph, + filterBundledDevClientReferences, + renderBundledDevClientReferences, +} from './bundled-dev' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' @@ -51,6 +56,7 @@ import { getEntrySource, hashString, normalizeRelativePath, + normalizeRollupOpitonsInput, getFetchHandlerExport, sortObject, withRollupError, @@ -107,6 +113,7 @@ const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` // dev-only wrapper virtual module of rollupOptions.input.index const VIRTUAL_ENTRIES = { browser: 'virtual:vite-rsc/entry-browser', + refreshPreamble: 'virtual:vite-rsc/refresh-preamble', } const require = createRequire(import.meta.url) @@ -125,6 +132,11 @@ class RscPluginManager { config!: ResolvedConfig bundles: Record = {} buildAssetsManifest: AssetsManifest | undefined + devBrowserEntrySource: string | undefined + devClientAssetsManifest: AssetsManifest | undefined + devServerAssetUrls: Map = new Map() + // This only gates metadata produced by generateBundle, not output commit. + devClientManifestReady!: Promise isScanBuild: boolean = false clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = @@ -142,6 +154,19 @@ class RscPluginManager { > > = {} + private resolveDevClientManifestReady!: () => void + + constructor() { + this.devClientManifestReady = new Promise((resolve) => { + this.resolveDevClientManifestReady = resolve + }) + } + + setDevClientAssetsManifest(manifest: AssetsManifest): void { + this.devClientAssetsManifest = manifest + this.resolveDevClientManifestReady() + } + stabilize(): void { // sort for stable build this.clientReferenceMetaMap = sortObject(this.clientReferenceMetaMap) @@ -523,6 +548,10 @@ export default function vitePluginRsc( const reactServerDomPackageName = hasReactServerDomWebpack ? 'react-server-dom-webpack' : REACT_SERVER_DOM_NAME + const isBundledDev = + env.command === 'serve' && + (config.environments?.client?.isBundled === true || + config.experimental?.bundledDev === true) return { appType: config.appType ?? 'custom', @@ -530,6 +559,8 @@ export default function vitePluginRsc( 'import.meta.env.__vite_rsc_build__': JSON.stringify( env.command === 'build', ), + 'import.meta.env.__vite_rsc_bundled_dev__': + JSON.stringify(isBundledDev), }, environments: { client: { @@ -617,10 +648,28 @@ export default function vitePluginRsc( }, } }, - configResolved() { + configResolved(config) { if (Number(vite.version.split('.')[0]) >= 7) { rscPluginOptions.useBuildAppHook ??= true } + + const browserEnvironment = config.environments.client + if ( + config.command === 'serve' && + browserEnvironment?.isBundled && + !rscPluginOptions.customClientEntry + ) { + manager.devBrowserEntrySource = getEntrySource( + browserEnvironment, + 'index', + ) + browserEnvironment.build.rollupOptions.input = { + ...normalizeRollupOpitonsInput( + browserEnvironment.build.rollupOptions.input, + ), + index: VIRTUAL_ENTRIES.browser, + } + } }, buildApp: { async handler(builder) { @@ -1084,9 +1133,51 @@ export function createRpcClient(params) { order: 'post', handler(_options, bundle) { manager.bundles[this.environment.name] = bundle + if ( + this.environment.mode === 'dev' && + this.environment.name === 'client' && + this.environment.config.isBundled + ) { + updateBundledDevAssetUrls(bundle, manager) + const manifest = createBundledDevAssetsManifest( + bundle, + manager, + rscPluginOptions, + ) + if (manifest) { + manager.setDevClientAssetsManifest(manifest) + } + } }, }, }, + { + name: 'rsc:bundled-dev-server-assets', + enforce: 'post', + async transform(_code, id) { + if (this.environment.mode !== 'dev') return + if ( + this.environment.name === 'client' || + !manager.config.environments.client?.isBundled + ) { + return + } + + const { filename, query } = parseIdQuery(id) + if ( + (!manager.config.assetsInclude(filename) && !('url' in query)) || + 'raw' in query || + 'inline' in query + ) { + return + } + + await manager.devClientManifestReady + const url = manager.devServerAssetUrls.get(normalizePath(filename)) + if (!url) return + return { code: `export default ${JSON.stringify(url)}`, map: null } + }, + }, { name: 'rsc:virtual:vite-rsc/assets-manifest', resolveId: { @@ -1102,10 +1193,15 @@ export function createRpcClient(params) { }, load: { filter: { id: exactRegex('\0virtual:vite-rsc/assets-manifest') }, - handler(id) { + async handler(id) { if (id === '\0virtual:vite-rsc/assets-manifest') { assert(this.environment.name !== 'client') assert(this.environment.mode === 'dev') + if (manager.config.environments.client?.isBundled) { + await manager.devClientManifestReady + assert(manager.devClientAssetsManifest) + return `export default ${serializeValueWithRuntime(manager.devClientAssetsManifest)}` + } const entryUrl = assetsURL( '@id/__x00__' + VIRTUAL_ENTRIES.browser, manager, @@ -1123,6 +1219,7 @@ export function createRpcClient(params) { generateBundle(_options, bundle) { // copy assets from rsc build to client build if (this.environment.name === 'client') { + if (this.environment.mode === 'dev') return const rscBundle = manager.bundles['rsc']! // when css code split is disabled, treat vite's single css bundle `style.css` @@ -1307,25 +1404,34 @@ export default assetsManifest.bootstrapScriptContent; }, }, createVirtualPlugin( - VIRTUAL_ENTRIES.browser.slice('virtual:'.length), + VIRTUAL_ENTRIES.refreshPreamble.slice('virtual:'.length), async function () { assert(this.environment.mode === 'dev') - let code = '' - // enable hmr only when react plugin is available - const resolved = await this.resolve('/@react-refresh') - if (resolved) { - code += ` + if (!(await this.resolve('/@react-refresh'))) return '' + return ` import RefreshRuntime from "/@react-refresh"; RefreshRuntime.injectIntoGlobalHook(window); window.$RefreshReg$ = () => {}; window.$RefreshSig$ = () => (type) => type; window.__vite_plugin_react_preamble_installed__ = true; ` - } - const source = getEntrySource(this.environment.config, 'index') + }, + ), + createVirtualPlugin( + VIRTUAL_ENTRIES.browser.slice('virtual:'.length), + async function () { + assert(this.environment.mode === 'dev') + let code = `import ${JSON.stringify(VIRTUAL_ENTRIES.refreshPreamble)};\n` + const source = + manager.devBrowserEntrySource ?? + getEntrySource(this.environment.config, 'index') const resolvedEntry = await this.resolve(source) assert(resolvedEntry, `[vite-rsc] failed to resolve entry '${source}'`) - code += `await import(${JSON.stringify(resolvedEntry.id)});` + if (this.environment.config.isBundled) { + code += `import ${JSON.stringify(withResolvedIdProxy(resolvedEntry.id))};` + } else { + code += `await import(${JSON.stringify(resolvedEntry.id)});` + } // server css is normally removed via `RemoveDuplicateServerCss` on useEffect. // this also makes sure they are removed on hmr in case initial rendering failed. code += /* js */ ` @@ -1507,6 +1613,10 @@ function vitePluginUseClient( let importId: string let referenceKey: string const packageSource = packageSources.get(id) + const isBrowserBundled = + this.environment.mode === 'dev' && + manager.server.environments[browserEnvironmentName]?.config + .isBundled if ( !packageSource && this.environment.mode === 'dev' && @@ -1521,13 +1631,15 @@ function vitePluginUseClient( `internal client reference created through a package imported in '${this.environment.name}' environment: ${id}`, ) id = cleanUrl(id) - warnInoncistentClientOptimization(this, id) - importId = `/@id/__x00__virtual:vite-rsc/client-in-server-package-proxy/${encodeURIComponent(id)}` - referenceKey = importId + if (!isBrowserBundled) { + warnInoncistentClientOptimization(this, id) + } + referenceKey = `/@id/__x00__virtual:vite-rsc/client-in-server-package-proxy/${encodeURIComponent(id)}` + importId = isBrowserBundled ? id : referenceKey } else if (packageSource) { if (this.environment.mode === 'dev') { - importId = `/@id/__x00__virtual:vite-rsc/client-package-proxy/${packageSource}` - referenceKey = importId + referenceKey = `/@id/__x00__virtual:vite-rsc/client-package-proxy/${packageSource}` + importId = isBrowserBundled ? packageSource : referenceKey } else { importId = packageSource referenceKey = hashString(packageSource) @@ -1539,6 +1651,9 @@ function vitePluginUseClient( id, ) referenceKey = importId + if (isBrowserBundled) { + importId = id + } } else { importId = id referenceKey = hashString(manager.toRelativeId(id)) @@ -1596,11 +1711,55 @@ function vitePluginUseClient( }, load: { filter: { id: prefixRegex('\0virtual:vite-rsc/client-references') }, - handler(id) { + async handler(id) { if (id === '\0virtual:vite-rsc/client-references') { - // not used during dev if (this.environment.mode === 'dev') { - return { code: `export default {}`, map: null } + if (!this.environment.config.isBundled) { + return { code: `export default {}`, map: null } + } + + const serverEnvironment = + manager.server.environments[serverEnvironmentName]! + let scanResult!: Awaited< + ReturnType + > + const previousClientReferences = manager.clientReferenceMetaMap + manager.clientReferenceMetaMap = { ...previousClientReferences } + try { + scanResult = await crawlBundledDevServerGraph( + serverEnvironment, + Object.values( + normalizeRollupOpitonsInput( + serverEnvironment.config.build.rollupOptions.input, + ), + ), + ) + // Registering these modules with addWatchFile is deferred until + // Vite exposes an atomic bundledDev rebuild-and-reload operation. + if (scanResult.cssImports.size > 0) { + const resolved = + await serverEnvironment.pluginContainer.resolveId( + 'virtual:vite-rsc/remove-duplicate-server-css', + ) + assert(resolved) + scanResult.moduleIds.add(resolved.id) + await serverEnvironment.transformRequest(resolved.id) + } + manager.clientReferenceMetaMap = + filterBundledDevClientReferences( + manager.clientReferenceMetaMap, + scanResult.moduleIds, + ) + } catch (error) { + manager.clientReferenceMetaMap = previousClientReferences + throw error + } + + const code = renderBundledDevClientReferences( + scanResult, + manager.clientReferenceMetaMap, + ) + return { code, map: null } } // no custom chunking needed for scan if (manager.isScanBuild) { @@ -2226,6 +2385,59 @@ function assetsURLOfDeps(deps: AssetDeps, manager: RscPluginManager) { } } +function createBundledDevAssetsManifest( + bundle: Rollup.OutputBundle, + manager: RscPluginManager, + options: Pick, +): AssetsManifest | undefined { + const assetDeps = collectAssetDeps(bundle) + const entry = options.customClientEntry + ? undefined + : Object.values(assetDeps).find( + (value) => value.chunk.isEntry && value.chunk.name === 'index', + ) + if (!options.customClientEntry && !entry) return + + const clientEntryDeps = entry + ? assetsURLOfDeps(entry.deps, manager) + : undefined + const clientReferenceDeps = Object.fromEntries( + Object.values(manager.clientReferenceMetaMap).map((meta) => [ + meta.referenceKey, + clientEntryDeps ?? { js: [], css: [] }, + ]), + ) + + return { + bootstrapScriptContent: entry + ? `import(${JSON.stringify(assetsURL(entry.chunk.fileName, manager))})` + : '', + clientEntryDeps, + clientReferenceDeps, + cssLinkPrecedence: options.cssLinkPrecedence, + } +} + +function updateBundledDevAssetUrls( + bundle: Rollup.OutputBundle, + manager: RscPluginManager, +): void { + for (const output of Object.values(bundle)) { + if (output.type !== 'asset') continue + for (const originalFileName of output.originalFileNames) { + const file = path.isAbsolute(originalFileName) + ? originalFileName + : path.resolve(manager.config.root, originalFileName) + const url = assetsURL(output.fileName, manager) + assert( + typeof url === 'string', + '[vite-rsc] runtime asset URLs are not supported with bundled dev', + ) + manager.devServerAssetUrls.set(normalizePath(file), url) + } + } +} + // // collect client reference dependency chunk for modulepreload // diff --git a/packages/plugin-rsc/types/index.d.ts b/packages/plugin-rsc/types/index.d.ts index 5eafb4ee1..aec12dc67 100644 --- a/packages/plugin-rsc/types/index.d.ts +++ b/packages/plugin-rsc/types/index.d.ts @@ -32,6 +32,7 @@ declare global { interface ImportMetaEnv { readonly __vite_rsc_build__: boolean + readonly __vite_rsc_bundled_dev__: boolean } }