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
//