diff --git a/packages/plugin-rsc/e2e/css-code-split.test.ts b/packages/plugin-rsc/e2e/css-code-split.test.ts new file mode 100644 index 000000000..97c98a07f --- /dev/null +++ b/packages/plugin-rsc/e2e/css-code-split.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { waitForHydration } from './helper' +import { defineStarterTest } from './starter' + +// When `build.cssCodeSplit: false`, Vite emits a single consolidated CSS +// bundle asset and no `importedCss` metadata on chunks. The rsc plugin must +// still copy the rsc environment's CSS asset into the client output and +// reference it from server-component resources, otherwise RSC-only CSS +// (e.g. from a server component module) goes missing from the client build. + +test.describe('cssCodeSplit-false', () => { + const root = 'examples/e2e/temp/cssCodeSplit-false' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + build: { cssCodeSplit: false }, + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + } + }), + ], + }) + `, + // CSS module imported exclusively by a server component module, so its + // styles only reach the client via plugin-rsc's server-resources + // path. + 'src/server-only.module.css': /* css */ ` + .serverOnly { + color: rgb(123, 45, 67); + } + `, + 'src/server-only.tsx': /* js */ ` + import styles from './server-only.module.css' + export function ServerOnly() { + return
rsc-css-only
+ } + `, + 'src/root.tsx': { + edit: (s) => + s + .replace( + `import { ClientCounter } from './client.tsx'`, + `import { ClientCounter } from './client.tsx'\nimport { ServerOnly } from './server-only.tsx'`, + ) + .replace(``, ``), + }, + }, + }) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + + test('rsc-only css is present in the client output', () => { + const dir = path.join(f.root, 'dist/client/assets') + const cssFiles = fs.readdirSync(dir).filter((n) => n.endsWith('.css')) + const combined = cssFiles + .map((n) => fs.readFileSync(path.join(dir, n), 'utf-8')) + .join('\n') + // minifier may hex-encode; accept either form + expect(combined).toMatch(/rgb\(123,\s*45,\s*67\)|#7b2d43/i) + }) + + test('rsc-only css is applied at runtime', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('server-only')).toHaveCSS( + 'color', + 'rgb(123, 45, 67)', + ) + }) + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9141febba..6d8e7d77d 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1065,93 +1065,117 @@ export function createRpcClient(params) { }, }, // client build - generateBundle(_options, bundle) { - // copy assets from rsc build to client build - manager.bundles[this.environment.name] = bundle - - if (this.environment.name === 'client') { - const rscBundle = manager.bundles['rsc']! - const assets = new Set( - Object.values(rscBundle).flatMap((output) => - output.type === 'chunk' - ? [ - ...(output.viteMetadata?.importedCss ?? []), - ...(output.viteMetadata?.importedAssets ?? []), - ] - : [], - ), - ) - for (const fileName of assets) { - const asset = rscBundle[fileName] - assert(asset?.type === 'asset') - this.emitFile({ - type: 'asset', - fileName: asset.fileName, - source: asset.source, - }) - } + generateBundle: { + // run after vite's css plugin emits the consolidated stylesheet + // (relevant when `build.cssCodeSplit: false`). + order: 'post', + handler(_options, bundle) { + // copy assets from rsc build to client build + manager.bundles[this.environment.name] = bundle - const serverResources: Record = {} - const rscAssetDeps = collectAssetDeps(manager.bundles['rsc']!) - for (const [id, meta] of Object.entries( - manager.serverResourcesMetaMap, - )) { - serverResources[meta.key] = assetsURLOfDeps( - { - js: [], - css: rscAssetDeps[id]?.deps.css ?? [], - }, - manager, + if (this.environment.name === 'client') { + const rscBundle = manager.bundles['rsc']! + const assets = new Set( + Object.values(rscBundle).flatMap((output) => + output.type === 'chunk' + ? [ + ...(output.viteMetadata?.importedCss ?? []), + ...(output.viteMetadata?.importedAssets ?? []), + ] + : [], + ), ) - } - - const assetDeps = collectAssetDeps(bundle) - let bootstrapScriptContent: string | RuntimeAsset = '' - - const clientReferenceDeps: Record = {} - for (const meta of Object.values(manager.clientReferenceMetaMap)) { - const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { - js: [], - css: [], + // when the rsc environment has `cssCodeSplit: false`, Vite emits a + // single bundled CSS asset and chunks carry no `importedCss` metadata. + // Pick the bundled CSS asset(s) directly so they get copied and + // referenced by server resources. + const rscCssCodeSplit = + manager.config.environments.rsc?.build.cssCodeSplit + const rscBundledCssFileNames = + rscCssCodeSplit === false + ? collectBundledCssAssetFileNames(rscBundle) + : [] + for (const fileName of rscBundledCssFileNames) { + assets.add(fileName) + } + for (const fileName of assets) { + const asset = rscBundle[fileName] + assert(asset?.type === 'asset') + this.emitFile({ + type: 'asset', + fileName: asset.fileName, + source: asset.source, + }) } - clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( - deps, - manager, - ) - } - // When customClientEntry is enabled, don't require "index" entry - // and don't merge entry deps into client references - if (!rscPluginOptions.customClientEntry) { - const entry = Object.values(assetDeps).find( - (v) => v.chunk.name === 'index' && v.chunk.isEntry, - ) - if (!entry) { - throw new Error( - `[vite-rsc] Client build must have an entry chunk named "index". Use 'customClientEntry' option to disable this requirement.`, + const serverResources: Record = {} + const rscAssetDeps = collectAssetDeps(manager.bundles['rsc']!) + for (const [id, meta] of Object.entries( + manager.serverResourcesMetaMap, + )) { + const cssDeps = new Set(rscAssetDeps[id]?.deps.css ?? []) + if (rscCssCodeSplit === false) { + for (const fileName of rscBundledCssFileNames) { + cssDeps.add(fileName) + } + } + serverResources[meta.key] = assetsURLOfDeps( + { + js: [], + css: [...cssDeps], + }, + manager, ) } - const entryDeps = assetsURLOfDeps(entry.deps, manager) - for (const [key, deps] of Object.entries(clientReferenceDeps)) { - clientReferenceDeps[key] = mergeAssetDeps(deps, entryDeps) + + const assetDeps = collectAssetDeps(bundle) + let bootstrapScriptContent: string | RuntimeAsset = '' + + const clientReferenceDeps: Record = {} + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { + js: [], + css: [], + } + clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( + deps, + manager, + ) } - const entryUrl = assetsURL(entry.chunk.fileName, manager) - if (typeof entryUrl === 'string') { - bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` - } else { - bootstrapScriptContent = new RuntimeAsset( - `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + + // When customClientEntry is enabled, don't require "index" entry + // and don't merge entry deps into client references + if (!rscPluginOptions.customClientEntry) { + const entry = Object.values(assetDeps).find( + (v) => v.chunk.name === 'index' && v.chunk.isEntry, ) + if (!entry) { + throw new Error( + `[vite-rsc] Client build must have an entry chunk named "index". Use 'customClientEntry' option to disable this requirement.`, + ) + } + const entryDeps = assetsURLOfDeps(entry.deps, manager) + for (const [key, deps] of Object.entries(clientReferenceDeps)) { + clientReferenceDeps[key] = mergeAssetDeps(deps, entryDeps) + } + const entryUrl = assetsURL(entry.chunk.fileName, manager) + if (typeof entryUrl === 'string') { + bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` + } else { + bootstrapScriptContent = new RuntimeAsset( + `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + ) + } } - } - manager.buildAssetsManifest = { - bootstrapScriptContent, - clientReferenceDeps, - serverResources, - cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence, + manager.buildAssetsManifest = { + bootstrapScriptContent, + clientReferenceDeps, + serverResources, + cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence, + } } - } + }, }, // non-client builds can load assets manifest as external renderChunk(code, chunk) { @@ -2172,6 +2196,18 @@ function collectAssetDepsInner( } } +function collectBundledCssAssetFileNames( + bundle: Rollup.OutputBundle, +): string[] { + return Object.values(bundle) + .filter( + (output): output is Rollup.OutputAsset => + output.type === 'asset' && + output.originalFileNames?.includes('style.css'), + ) + .map((output) => output.fileName) +} + // // css support //