Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/plugin-rsc/e2e/css-code-split.test.ts
Original file line number Diff line number Diff line change
@@ -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 <link>
// 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 <div data-testid="server-only" className={styles.serverOnly}>rsc-css-only</div>
}
`,
'src/root.tsx': {
edit: (s) =>
s
.replace(
`import { ClientCounter } from './client.tsx'`,
`import { ClientCounter } from './client.tsx'\nimport { ServerOnly } from './server-only.tsx'`,
)
.replace(`<ClientCounter />`, `<ClientCounter /><ServerOnly />`),
},
},
})
})

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)',
)
})
})
})
188 changes: 112 additions & 76 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AssetDeps> = {}
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<string, AssetDeps> = {}
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<string, AssetDeps> = {}
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<string, AssetDeps> = {}
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) {
Expand Down Expand Up @@ -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
//
Expand Down
Loading