Skip to content

Commit e21fabe

Browse files
fix(rsc): include bundled server CSS when cssCodeSplit is false
1 parent 0389922 commit e21fabe

File tree

2 files changed

+203
-76
lines changed

2 files changed

+203
-76
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { expect, test } from '@playwright/test'
4+
import { setupInlineFixture, useFixture } from './fixture'
5+
import { waitForHydration } from './helper'
6+
import { defineStarterTest } from './starter'
7+
8+
// When `build.cssCodeSplit: false`, Vite emits a single consolidated CSS
9+
// bundle asset and no `importedCss` metadata on chunks. The rsc plugin must
10+
// still copy the rsc environment's CSS asset into the client output and
11+
// reference it from server-component resources, otherwise RSC-only CSS
12+
// (e.g. from a server component module) goes missing from the client build.
13+
14+
test.describe('cssCodeSplit-false', () => {
15+
const root = 'examples/e2e/temp/cssCodeSplit-false'
16+
17+
test.beforeAll(async () => {
18+
await setupInlineFixture({
19+
src: 'examples/starter',
20+
dest: root,
21+
files: {
22+
'vite.config.ts': /* js */ `
23+
import rsc from '@vitejs/plugin-rsc'
24+
import react from '@vitejs/plugin-react'
25+
import { defineConfig } from 'vite'
26+
27+
export default defineConfig({
28+
build: { cssCodeSplit: false },
29+
plugins: [
30+
react(),
31+
rsc({
32+
entries: {
33+
client: './src/framework/entry.browser.tsx',
34+
ssr: './src/framework/entry.ssr.tsx',
35+
rsc: './src/framework/entry.rsc.tsx',
36+
}
37+
}),
38+
],
39+
})
40+
`,
41+
// CSS module imported exclusively by a server component module, so its
42+
// styles only reach the client via plugin-rsc's server-resources <link>
43+
// path.
44+
'src/server-only.module.css': /* css */ `
45+
.serverOnly {
46+
color: rgb(123, 45, 67);
47+
}
48+
`,
49+
'src/server-only.tsx': /* js */ `
50+
import styles from './server-only.module.css'
51+
export function ServerOnly() {
52+
return <div data-testid="server-only" className={styles.serverOnly}>rsc-css-only</div>
53+
}
54+
`,
55+
'src/root.tsx': {
56+
edit: (s) =>
57+
s
58+
.replace(
59+
`import { ClientCounter } from './client.tsx'`,
60+
`import { ClientCounter } from './client.tsx'\nimport { ServerOnly } from './server-only.tsx'`,
61+
)
62+
.replace(`<ClientCounter />`, `<ClientCounter /><ServerOnly />`),
63+
},
64+
},
65+
})
66+
})
67+
68+
test.describe('build', () => {
69+
const f = useFixture({ root, mode: 'build' })
70+
defineStarterTest(f)
71+
72+
test('rsc-only css is present in the client output', () => {
73+
const dir = path.join(f.root, 'dist/client/assets')
74+
const cssFiles = fs.readdirSync(dir).filter((n) => n.endsWith('.css'))
75+
const combined = cssFiles
76+
.map((n) => fs.readFileSync(path.join(dir, n), 'utf-8'))
77+
.join('\n')
78+
// minifier may hex-encode; accept either form
79+
expect(combined).toMatch(/rgb\(123,\s*45,\s*67\)|#7b2d43/i)
80+
})
81+
82+
test('rsc-only css is applied at runtime', async ({ page }) => {
83+
await page.goto(f.url())
84+
await waitForHydration(page)
85+
await expect(page.getByTestId('server-only')).toHaveCSS(
86+
'color',
87+
'rgb(123, 45, 67)',
88+
)
89+
})
90+
})
91+
})

packages/plugin-rsc/src/plugin.ts

Lines changed: 112 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,93 +1065,117 @@ export function createRpcClient(params) {
10651065
},
10661066
},
10671067
// client build
1068-
generateBundle(_options, bundle) {
1069-
// copy assets from rsc build to client build
1070-
manager.bundles[this.environment.name] = bundle
1071-
1072-
if (this.environment.name === 'client') {
1073-
const rscBundle = manager.bundles['rsc']!
1074-
const assets = new Set(
1075-
Object.values(rscBundle).flatMap((output) =>
1076-
output.type === 'chunk'
1077-
? [
1078-
...(output.viteMetadata?.importedCss ?? []),
1079-
...(output.viteMetadata?.importedAssets ?? []),
1080-
]
1081-
: [],
1082-
),
1083-
)
1084-
for (const fileName of assets) {
1085-
const asset = rscBundle[fileName]
1086-
assert(asset?.type === 'asset')
1087-
this.emitFile({
1088-
type: 'asset',
1089-
fileName: asset.fileName,
1090-
source: asset.source,
1091-
})
1092-
}
1068+
generateBundle: {
1069+
// run after vite's css plugin emits the consolidated stylesheet
1070+
// (relevant when `build.cssCodeSplit: false`).
1071+
order: 'post',
1072+
handler(_options, bundle) {
1073+
// copy assets from rsc build to client build
1074+
manager.bundles[this.environment.name] = bundle
10931075

1094-
const serverResources: Record<string, AssetDeps> = {}
1095-
const rscAssetDeps = collectAssetDeps(manager.bundles['rsc']!)
1096-
for (const [id, meta] of Object.entries(
1097-
manager.serverResourcesMetaMap,
1098-
)) {
1099-
serverResources[meta.key] = assetsURLOfDeps(
1100-
{
1101-
js: [],
1102-
css: rscAssetDeps[id]?.deps.css ?? [],
1103-
},
1104-
manager,
1076+
if (this.environment.name === 'client') {
1077+
const rscBundle = manager.bundles['rsc']!
1078+
const assets = new Set(
1079+
Object.values(rscBundle).flatMap((output) =>
1080+
output.type === 'chunk'
1081+
? [
1082+
...(output.viteMetadata?.importedCss ?? []),
1083+
...(output.viteMetadata?.importedAssets ?? []),
1084+
]
1085+
: [],
1086+
),
11051087
)
1106-
}
1107-
1108-
const assetDeps = collectAssetDeps(bundle)
1109-
let bootstrapScriptContent: string | RuntimeAsset = ''
1110-
1111-
const clientReferenceDeps: Record<string, AssetDeps> = {}
1112-
for (const meta of Object.values(manager.clientReferenceMetaMap)) {
1113-
const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? {
1114-
js: [],
1115-
css: [],
1088+
// when the rsc environment has `cssCodeSplit: false`, Vite emits a
1089+
// single bundled CSS asset and chunks carry no `importedCss` metadata.
1090+
// Pick the bundled CSS asset(s) directly so they get copied and
1091+
// referenced by server resources.
1092+
const rscCssCodeSplit =
1093+
manager.config.environments.rsc?.build.cssCodeSplit
1094+
const rscBundledCssFileNames =
1095+
rscCssCodeSplit === false
1096+
? collectBundledCssAssetFileNames(rscBundle)
1097+
: []
1098+
for (const fileName of rscBundledCssFileNames) {
1099+
assets.add(fileName)
1100+
}
1101+
for (const fileName of assets) {
1102+
const asset = rscBundle[fileName]
1103+
assert(asset?.type === 'asset')
1104+
this.emitFile({
1105+
type: 'asset',
1106+
fileName: asset.fileName,
1107+
source: asset.source,
1108+
})
11161109
}
1117-
clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps(
1118-
deps,
1119-
manager,
1120-
)
1121-
}
11221110

1123-
// When customClientEntry is enabled, don't require "index" entry
1124-
// and don't merge entry deps into client references
1125-
if (!rscPluginOptions.customClientEntry) {
1126-
const entry = Object.values(assetDeps).find(
1127-
(v) => v.chunk.name === 'index' && v.chunk.isEntry,
1128-
)
1129-
if (!entry) {
1130-
throw new Error(
1131-
`[vite-rsc] Client build must have an entry chunk named "index". Use 'customClientEntry' option to disable this requirement.`,
1111+
const serverResources: Record<string, AssetDeps> = {}
1112+
const rscAssetDeps = collectAssetDeps(manager.bundles['rsc']!)
1113+
for (const [id, meta] of Object.entries(
1114+
manager.serverResourcesMetaMap,
1115+
)) {
1116+
const cssDeps = new Set(rscAssetDeps[id]?.deps.css ?? [])
1117+
if (rscCssCodeSplit === false) {
1118+
for (const fileName of rscBundledCssFileNames) {
1119+
cssDeps.add(fileName)
1120+
}
1121+
}
1122+
serverResources[meta.key] = assetsURLOfDeps(
1123+
{
1124+
js: [],
1125+
css: [...cssDeps],
1126+
},
1127+
manager,
11321128
)
11331129
}
1134-
const entryDeps = assetsURLOfDeps(entry.deps, manager)
1135-
for (const [key, deps] of Object.entries(clientReferenceDeps)) {
1136-
clientReferenceDeps[key] = mergeAssetDeps(deps, entryDeps)
1130+
1131+
const assetDeps = collectAssetDeps(bundle)
1132+
let bootstrapScriptContent: string | RuntimeAsset = ''
1133+
1134+
const clientReferenceDeps: Record<string, AssetDeps> = {}
1135+
for (const meta of Object.values(manager.clientReferenceMetaMap)) {
1136+
const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? {
1137+
js: [],
1138+
css: [],
1139+
}
1140+
clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps(
1141+
deps,
1142+
manager,
1143+
)
11371144
}
1138-
const entryUrl = assetsURL(entry.chunk.fileName, manager)
1139-
if (typeof entryUrl === 'string') {
1140-
bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})`
1141-
} else {
1142-
bootstrapScriptContent = new RuntimeAsset(
1143-
`"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`,
1145+
1146+
// When customClientEntry is enabled, don't require "index" entry
1147+
// and don't merge entry deps into client references
1148+
if (!rscPluginOptions.customClientEntry) {
1149+
const entry = Object.values(assetDeps).find(
1150+
(v) => v.chunk.name === 'index' && v.chunk.isEntry,
11441151
)
1152+
if (!entry) {
1153+
throw new Error(
1154+
`[vite-rsc] Client build must have an entry chunk named "index". Use 'customClientEntry' option to disable this requirement.`,
1155+
)
1156+
}
1157+
const entryDeps = assetsURLOfDeps(entry.deps, manager)
1158+
for (const [key, deps] of Object.entries(clientReferenceDeps)) {
1159+
clientReferenceDeps[key] = mergeAssetDeps(deps, entryDeps)
1160+
}
1161+
const entryUrl = assetsURL(entry.chunk.fileName, manager)
1162+
if (typeof entryUrl === 'string') {
1163+
bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})`
1164+
} else {
1165+
bootstrapScriptContent = new RuntimeAsset(
1166+
`"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`,
1167+
)
1168+
}
11451169
}
1146-
}
11471170

1148-
manager.buildAssetsManifest = {
1149-
bootstrapScriptContent,
1150-
clientReferenceDeps,
1151-
serverResources,
1152-
cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence,
1171+
manager.buildAssetsManifest = {
1172+
bootstrapScriptContent,
1173+
clientReferenceDeps,
1174+
serverResources,
1175+
cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence,
1176+
}
11531177
}
1154-
}
1178+
},
11551179
},
11561180
// non-client builds can load assets manifest as external
11571181
renderChunk(code, chunk) {
@@ -2172,6 +2196,18 @@ function collectAssetDepsInner(
21722196
}
21732197
}
21742198

2199+
function collectBundledCssAssetFileNames(
2200+
bundle: Rollup.OutputBundle,
2201+
): string[] {
2202+
return Object.values(bundle)
2203+
.filter(
2204+
(output): output is Rollup.OutputAsset =>
2205+
output.type === 'asset' &&
2206+
output.originalFileNames?.includes('style.css'),
2207+
)
2208+
.map((output) => output.fileName)
2209+
}
2210+
21752211
//
21762212
// css support
21772213
//

0 commit comments

Comments
 (0)