diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index 42f09e714..3c64d5470 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test' import { setupInlineFixture, useFixture, type Fixture } from './fixture' import { defineStarterTest } from './starter' import { expectNoPageError, waitForHydration } from './helper' +import { x } from 'tinyexec' test.describe('dev-default', () => { const f = useFixture({ root: 'examples/starter', mode: 'dev' }) @@ -94,3 +95,49 @@ test.describe('duplicate loadCss', () => { }) } }) + +test.describe('isolated build', () => { + const root = 'examples/e2e/temp/isolated-build' + + test.beforeAll(async () => { + // build twice programmatically to verify two plugin states are independent + async function testFn() { + const vite = await import('vite') + const fs = await import('node:fs') + + console.log('======== first build ========') + const builder1 = await vite.createBuilder() + await builder1.buildApp() + + // edit files to remove client references + fs.rmSync(`src/client.tsx`) + fs.writeFileSync( + `src/root.tsx`, + fs + .readFileSync(`src/root.tsx`, 'utf-8') + .replace(`import { ClientCounter } from './client.tsx'`, '') + .replace(``, ''), + ) + + console.log('======== second build ========') + const builder2 = await vite.createBuilder() + await builder2.buildApp() + } + + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'test.js': `await (${testFn.toString()})();\n`, + }, + }) + }) + + test('build', async () => { + const result = await x('node', ['./test.js'], { + nodeOptions: { cwd: root }, + }) + expect(result.stderr).not.toContain('Build failed') + expect(result.exitCode).toBe(0) + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 543a63622..d00343015 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -44,13 +44,6 @@ import { transformScanBuildStrip } from './plugins/scan' import { validateImportPlugin } from './plugins/validate-import' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' -// state for build orchestration -let serverReferences: Record = {} -let server: ViteDevServer -let config: ResolvedConfig -let rscBundle: Rollup.OutputBundle -let buildAssetsManifest: AssetsManifest | undefined -let isScanBuild = false const BUILD_ASSETS_MANIFEST_NAME = '__vite_rsc_assets_manifest.js' type ClientReferenceMeta = { @@ -62,9 +55,6 @@ type ClientReferenceMeta = { exportNames: string[] renderedExports: string[] } -let clientReferenceMetaMap: Record = {} - -let serverResourcesMetaMap: Record = {} const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -80,6 +70,17 @@ function resolvePackage(name: string) { return pathToFileURL(require.resolve(name)).href } +class RscPluginManager { + server!: ViteDevServer + config!: ResolvedConfig + rscBundle!: Rollup.OutputBundle + buildAssetsManifest: AssetsManifest | undefined + isScanBuild: boolean = false + serverReferences: Record = {} + clientReferenceMetaMap: Record = {} + serverResourcesMetaMap: Record = {} +} + export type RscPluginOptions = { /** * shorthand for configuring `environments.(name).build.rollupOptions.input.index` @@ -158,6 +159,7 @@ export type RscPluginOptions = { /** @experimental */ export function vitePluginRscMinimal( rscPluginOptions: RscPluginOptions = {}, + manager: RscPluginManager = new RscPluginManager(), ): Plugin[] { return [ { @@ -166,8 +168,8 @@ export function vitePluginRscMinimal( async config() { await esModuleLexer.init }, - configResolved(config_) { - config = config_ + configResolved(config) { + manager.config = config // ensure outDir is fully resolved to take custom root into account // https://github.com/vitejs/vite/blob/946831f986cb797009b8178659d2b31f570c44ff/packages/vite/src/node/build.ts#L574 for (const e of Object.values(config.environments)) { @@ -175,7 +177,7 @@ export function vitePluginRscMinimal( } }, configureServer(server_) { - server = server_ + manager.server = server_ }, }, { @@ -192,8 +194,8 @@ export function vitePluginRscMinimal( }, }, ...vitePluginRscCore(), - ...vitePluginUseClient(rscPluginOptions), - ...vitePluginUseServer(rscPluginOptions), + ...vitePluginUseClient(rscPluginOptions, manager), + ...vitePluginUseServer(rscPluginOptions, manager), ...vitePluginDefineEncryptionKey(rscPluginOptions), ] } @@ -201,40 +203,46 @@ export function vitePluginRscMinimal( export default function vitePluginRsc( rscPluginOptions: RscPluginOptions = {}, ): Plugin[] { + const manager = new RscPluginManager() + const buildApp: NonNullable = async (builder) => { // no-ssr case // rsc -> client -> rsc -> client if (!builder.environments.ssr?.config.build.rollupOptions.input) { - isScanBuild = true + manager.isScanBuild = true builder.environments.rsc!.config.build.write = false builder.environments.client!.config.build.write = false await builder.build(builder.environments.rsc!) await builder.build(builder.environments.client!) - isScanBuild = false + manager.isScanBuild = false builder.environments.rsc!.config.build.write = true builder.environments.client!.config.build.write = true await builder.build(builder.environments.rsc!) // sort for stable build - clientReferenceMetaMap = sortObject(clientReferenceMetaMap) - serverResourcesMetaMap = sortObject(serverResourcesMetaMap) + manager.clientReferenceMetaMap = sortObject( + manager.clientReferenceMetaMap, + ) + manager.serverResourcesMetaMap = sortObject( + manager.serverResourcesMetaMap, + ) await builder.build(builder.environments.client!) writeAssetsManifest(['rsc']) return } // rsc -> ssr -> rsc -> client -> ssr - isScanBuild = true + manager.isScanBuild = true builder.environments.rsc!.config.build.write = false builder.environments.ssr!.config.build.write = false await builder.build(builder.environments.rsc!) await builder.build(builder.environments.ssr!) - isScanBuild = false + manager.isScanBuild = false builder.environments.rsc!.config.build.write = true builder.environments.ssr!.config.build.write = true await builder.build(builder.environments.rsc!) // sort for stable build - clientReferenceMetaMap = sortObject(clientReferenceMetaMap) - serverResourcesMetaMap = sortObject(serverResourcesMetaMap) + manager.clientReferenceMetaMap = sortObject(manager.clientReferenceMetaMap) + manager.serverResourcesMetaMap = sortObject(manager.serverResourcesMetaMap) await builder.build(builder.environments.client!) await builder.build(builder.environments.ssr!) writeAssetsManifest(['ssr', 'rsc']) @@ -244,11 +252,11 @@ export default function vitePluginRsc( // output client manifest to non-client build directly. // this makes server build to be self-contained and deploy-able for cloudflare. const assetsManifestCode = `export default ${serializeValueWithRuntime( - buildAssetsManifest, + manager.buildAssetsManifest, )}` for (const name of environmentNames) { const manifestPath = path.join( - config.environments[name]!.build.outDir, + manager.config.environments[name]!.build.outDir, BUILD_ASSETS_MANIFEST_NAME, ) fs.writeFileSync(manifestPath, assetsManifestCode) @@ -366,7 +374,7 @@ export default function vitePluginRsc( } }, buildApp: rscPluginOptions.useBuildAppHook ? buildApp : undefined, - configureServer() { + configureServer(server) { ;(globalThis as any).__viteRscDevServer = server if (rscPluginOptions.disableServerHandler) return @@ -409,7 +417,7 @@ export default function vitePluginRsc( } const entryFile = path.join( - config.environments[options.environmentName]!.build.outDir, + manager.config.environments[options.environmentName]!.build.outDir, `${options.entryName}.js`, ) const entry = pathToFileURL(entryFile).href @@ -452,7 +460,7 @@ export default function vitePluginRsc( const visited = new Set() function recurse(mod: EnvironmentModuleNode): boolean { if (!mod.id) return false - if (clientReferenceMetaMap[mod.id]) return true + if (manager.clientReferenceMetaMap[mod.id]) return true if (visited.has(mod.id)) return false visited.add(mod.id) for (const importer of mod.importers) { @@ -485,7 +493,7 @@ export default function vitePluginRsc( try { await this.environment.transformRequest(mod.url) } catch (e) { - server.environments.client.hot.send({ + manager.server.environments.client.hot.send({ type: 'error', err: prepareError(e as any), }) @@ -543,6 +551,7 @@ export default function vitePluginRsc( name: 'rsc:load-environment-module', async transform(code) { if (!code.includes('import.meta.viteRsc.loadModule')) return + const { server } = manager const s = new MagicString(code) for (const match of code.matchAll( /import\.meta\.viteRsc\.loadModule\(([\s\S]*?)\)/dg, @@ -592,6 +601,7 @@ export default function vitePluginRsc( }, renderChunk(code, chunk) { if (!code.includes('__vite_rsc_load_module')) return + const { config } = manager const s = new MagicString(code) for (const match of code.matchAll( /['"]__vite_rsc_load_module:(\w+):(\w+):(\w+)['"]/dg, @@ -689,7 +699,10 @@ export default function vitePluginRsc( if (id === '\0virtual:vite-rsc/assets-manifest') { assert(this.environment.name !== 'client') assert(this.environment.mode === 'dev') - const entryUrl = assetsURL('@id/__x00__' + VIRTUAL_ENTRIES.browser) + const entryUrl = assetsURL( + '@id/__x00__' + VIRTUAL_ENTRIES.browser, + manager, + ) const manifest: AssetsManifest = { bootstrapScriptContent: `import(${serializeValueWithRuntime(entryUrl)})`, clientReferenceDeps: {}, @@ -701,18 +714,18 @@ export default function vitePluginRsc( generateBundle(_options, bundle) { // copy assets from rsc build to client build if (this.environment.name === 'rsc') { - rscBundle = bundle + manager.rscBundle = bundle } if (this.environment.name === 'client') { const filterAssets = rscPluginOptions.copyServerAssetsToClient ?? (() => true) - const rscBuildOptions = config.environments.rsc!.build + const rscBuildOptions = manager.config.environments.rsc!.build const rscViteManifest = typeof rscBuildOptions.manifest === 'string' ? rscBuildOptions.manifest : rscBuildOptions.manifest && '.vite/manifest.json' - for (const asset of Object.values(rscBundle)) { + for (const asset of Object.values(manager.rscBundle)) { if (asset.fileName === rscViteManifest) continue if (asset.type === 'asset' && filterAssets(asset.fileName)) { this.emitFile({ @@ -724,12 +737,17 @@ export default function vitePluginRsc( } const serverResources: Record = {} - const rscAssetDeps = collectAssetDeps(rscBundle) - for (const [id, meta] of Object.entries(serverResourcesMetaMap)) { - serverResources[meta.key] = assetsURLOfDeps({ - js: [], - css: rscAssetDeps[id]?.deps.css ?? [], - }) + const rscAssetDeps = collectAssetDeps(manager.rscBundle) + for (const [id, meta] of Object.entries( + manager.serverResourcesMetaMap, + )) { + serverResources[meta.key] = assetsURLOfDeps( + { + js: [], + css: rscAssetDeps[id]?.deps.css ?? [], + }, + manager, + ) } const assetDeps = collectAssetDeps(bundle) @@ -737,12 +755,15 @@ export default function vitePluginRsc( (v) => v.chunk.name === 'index', ) assert(entry) - const entryUrl = assetsURL(entry.chunk.fileName) + const entryUrl = assetsURL(entry.chunk.fileName, manager) const clientReferenceDeps: Record = {} - for (const [id, meta] of Object.entries(clientReferenceMetaMap)) { + for (const [id, meta] of Object.entries( + manager.clientReferenceMetaMap, + )) { const deps: AssetDeps = assetDeps[id]?.deps ?? { js: [], css: [] } clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( mergeAssetDeps(deps, entry.deps), + manager, ) } let bootstrapScriptContent: string | RuntimeAsset @@ -753,7 +774,7 @@ export default function vitePluginRsc( `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, ) } - buildAssetsManifest = { + manager.buildAssetsManifest = { bootstrapScriptContent, clientReferenceDeps, serverResources, @@ -893,26 +914,30 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage; return '' }, }, - ...vitePluginRscMinimal(rscPluginOptions), + ...vitePluginRscMinimal(rscPluginOptions, manager), ...vitePluginFindSourceMapURL(), - ...vitePluginRscCss({ rscCssTransform: rscPluginOptions.rscCssTransform }), + ...vitePluginRscCss(rscPluginOptions, manager), ...(rscPluginOptions.validateImports !== false ? [validateImportPlugin()] : []), - scanBuildStripPlugin(), + scanBuildStripPlugin({ manager }), ...cjsModuleRunnerPlugin(), ] } // During scan build, we strip all code but imports to // traverse module graph faster and just discover client/server references. -function scanBuildStripPlugin(): Plugin { +function scanBuildStripPlugin({ + manager, +}: { + manager: RscPluginManager +}): Plugin { return { name: 'rsc:scan-strip', apply: 'build', enforce: 'post', async transform(code, _id, _options) { - if (!isScanBuild) return + if (!manager.isScanBuild) return const output = await transformScanBuildStrip(code) return { code: output, map: { mappings: '' } } }, @@ -948,6 +973,7 @@ function vitePluginUseClient( RscPluginOptions, 'keepUseCientProxy' | 'environment' >, + manager: RscPluginManager, ): Plugin[] { const packageSources = new Map() @@ -1019,14 +1045,14 @@ function vitePluginUseClient( } else { if (this.environment.mode === 'dev') { importId = normalizeViteImportAnalysisUrl( - server.environments[browserEnvironmentName]!, + manager.server.environments[browserEnvironmentName]!, id, ) referenceKey = importId } else { importId = id referenceKey = hashString( - normalizePath(path.relative(config.root, id)), + normalizePath(path.relative(manager.config.root, id)), ) } } @@ -1057,7 +1083,7 @@ function vitePluginUseClient( }) if (!result) return const { output, exportNames } = result - clientReferenceMetaMap[id] = { + manager.clientReferenceMetaMap[id] = { importId, referenceKey, packageSource, @@ -1074,7 +1100,7 @@ function vitePluginUseClient( return { code: `export default {}`, map: null } } let code = '' - for (const meta of Object.values(clientReferenceMetaMap)) { + for (const meta of Object.values(manager.clientReferenceMetaMap)) { // vite/rollup can apply tree-shaking to dynamic import of this form const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) @@ -1136,7 +1162,7 @@ function vitePluginUseClient( const source = id.slice( '\0virtual:vite-rsc/client-package-proxy/'.length, ) - const meta = Object.values(clientReferenceMetaMap).find( + const meta = Object.values(manager.clientReferenceMetaMap).find( (v) => v.packageSource === source, )! const exportNames = meta.exportNames @@ -1153,7 +1179,7 @@ function vitePluginUseClient( for (const chunk of Object.values(bundle)) { if (chunk.type === 'chunk') { for (const [id, mod] of Object.entries(chunk.modules)) { - const meta = clientReferenceMetaMap[id] + const meta = manager.clientReferenceMetaMap[id] if (meta) { meta.renderedExports = mod.renderedExports } @@ -1332,6 +1358,7 @@ function vitePluginUseServer( RscPluginOptions, 'enableActionEncryption' | 'environment' >, + manager: RscPluginManager, ): Plugin[] { const serverEnvironmentName = useServerPluginOptions.environment?.rsc ?? 'rsc' const browserEnvironmentName = @@ -1361,11 +1388,11 @@ function vitePluginUseServer( ) id = cleanUrl(id) } - if (config.command === 'build') { - normalizedId_ = hashString(path.relative(config.root, id)) + if (manager.config.command === 'build') { + normalizedId_ = hashString(path.relative(manager.config.root, id)) } else { normalizedId_ = normalizeViteImportAnalysisUrl( - server.environments[serverEnvironmentName]!, + manager.server.environments[serverEnvironmentName]!, id, ) } @@ -1396,7 +1423,7 @@ function vitePluginUseServer( : undefined, }) if (!output.hasChanged()) return - serverReferences[getNormalizedId()] = id + manager.serverReferences[getNormalizedId()] = id const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) output.prepend(`import * as $$ReactServer from "${importSource}";\n`) if (enableEncryption) { @@ -1433,7 +1460,7 @@ function vitePluginUseServer( }) const output = result?.output if (!output?.hasChanged()) return - serverReferences[getNormalizedId()] = id + manager.serverReferences[getNormalizedId()] = id const name = this.environment.name === browserEnvironmentName ? 'browser' : 'ssr' const importSource = resolvePackage(`${PKG_NAME}/react/${name}`) @@ -1449,7 +1476,7 @@ function vitePluginUseServer( if (this.environment.mode === 'dev') { return { code: `export {}`, map: null } } - const code = generateDynamicImportCode(serverReferences) + const code = generateDynamicImportCode(manager.serverReferences) return { code, map: null } }), ] @@ -1534,7 +1561,8 @@ function serializeValueWithRuntime(value: any) { return result } -function assetsURL(url: string) { +function assetsURL(url: string, manager: RscPluginManager) { + const { config } = manager if ( config.command === 'build' && typeof config.experimental?.renderBuiltUrl === 'function' @@ -1564,15 +1592,15 @@ function assetsURL(url: string) { return config.base + url } -function assetsURLOfDeps(deps: AssetDeps) { +function assetsURLOfDeps(deps: AssetDeps, manager: RscPluginManager) { return { js: deps.js.map((href) => { assert(typeof href === 'string') - return assetsURL(href) + return assetsURL(href, manager) }), css: deps.css.map((href) => { assert(typeof href === 'string') - return assetsURL(href) + return assetsURL(href, manager) }), } } @@ -1663,8 +1691,9 @@ function collectAssetDepsInner( // css support // -export function vitePluginRscCss( - rscCssOptions?: Pick, +function vitePluginRscCss( + rscCssOptions: Pick = {}, + manager: RscPluginManager, ): Plugin[] { function hasSpecialCssQuery(id: string): boolean { return /[?&](url|inline|raw)(\b|=|&|$)/.test(id) @@ -1782,6 +1811,7 @@ export function vitePluginRscCss( async load(id) { if (id.startsWith('\0virtual:vite-rsc/css/dev-ssr/')) { id = id.slice('\0virtual:vite-rsc/css/dev-ssr/'.length) + const { server } = manager const mod = await server.environments.ssr.moduleGraph.getModuleByUrl(id) if (!mod?.id || !mod?.file) { @@ -1792,7 +1822,9 @@ export function vitePluginRscCss( for (const file of [mod.file, ...result.visitedFiles]) { this.addWatchFile(file) } - const hrefs = result.hrefs.map((href) => assetsURL(href.slice(1))) + const hrefs = result.hrefs.map((href) => + assetsURL(href.slice(1), manager), + ) return `export default ${serializeValueWithRuntime(hrefs)}` } }, @@ -1873,6 +1905,7 @@ export function vitePluginRscCss( } }, load(id) { + const { server } = manager if (id.startsWith('\0virtual:vite-rsc/importer-resources?importer=')) { const importer = decodeURIComponent( parseIdQuery(id).query['importer']!, @@ -1884,17 +1917,26 @@ export function vitePluginRscCss( '@id/__x00__virtual:vite-rsc/importer-resources-browser?importer=' + encodeURIComponent(importer), ] - const deps = assetsURLOfDeps({ css: cssHrefs, js: jsHrefs }) - return generateResourcesCode(serializeValueWithRuntime(deps)) + const deps = assetsURLOfDeps( + { css: cssHrefs, js: jsHrefs }, + manager, + ) + return generateResourcesCode( + serializeValueWithRuntime(deps), + manager, + ) } else { - const key = normalizePath(path.relative(config.root, importer)) - serverResourcesMetaMap[importer] = { key } + const key = normalizePath( + path.relative(manager.config.root, importer), + ) + manager.serverResourcesMetaMap[importer] = { key } return ` import __vite_rsc_assets_manifest__ from "virtual:vite-rsc/assets-manifest"; ${generateResourcesCode( `__vite_rsc_assets_manifest__.serverResources[${JSON.stringify( key, )}]`, + manager, )} ` } @@ -1922,6 +1964,7 @@ export function vitePluginRscCss( }, hotUpdate(ctx) { if (this.environment.name === 'rsc') { + const { server } = manager const mods = collectModuleDependents(ctx.modules) for (const mod of mods) { if (mod.id) { @@ -1995,7 +2038,7 @@ function collectModuleDependents(mods: EnvironmentModuleNode[]) { return [...visited] } -function generateResourcesCode(depsCode: string) { +function generateResourcesCode(depsCode: string, manager: RscPluginManager) { const ResourcesFn = ( React: typeof import('react'), deps: ResolvedAssetDeps, @@ -2032,7 +2075,7 @@ function generateResourcesCode(depsCode: string) { import __vite_rsc_react__ from "react"; ${ - config.command === 'serve' + manager.config.command === 'serve' ? `import RemoveDuplicateServerCss from "virtual:vite-rsc/remove-duplicate-server-css";` : `const RemoveDuplicateServerCss = undefined;` }