Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_kQbMx_I1A9iUKgf2hMJkR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/vite-plugin/package.json": "Patch"
},
"note": "Avoid re-emitting identical Devup CSS chunks across Vite environment builds",
"date": "2026-08-30T10:18:56.558349Z"
}
145 changes: 143 additions & 2 deletions packages/vite-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface ViteConfig {

interface ViteTestPlugin {
name: string
sharedDuringBuild: true
enforce: 'pre'
apply: () => boolean
config: (
Expand All @@ -66,10 +67,38 @@ interface ViteTestPlugin {
timestamp: number
}) => Promise<unknown[] | undefined>
load: (id: string) => string | undefined
transform: (code: string, id: string) => Promise<{ code: string } | undefined>
transform: (
this: {
environment?: {
name: string
config: {
consumer: 'client' | 'server'
build?: { write?: boolean }
}
}
},
code: string,
id: string,
) => Promise<{ code: string } | undefined>
generateBundle: (
this: {
environment?: {
name: string
config: {
consumer: 'client' | 'server'
build?: { write?: boolean }
}
}
},
options: object,
bundle: Record<string, { source: string; name: string }>,
bundle: Record<
string,
{
source?: string
name: string
viteMetadata?: { importedCss?: Set<string> }
}
>,
) => Promise<void>
resolveId: (source: string, importer?: string) => string | undefined
}
Expand Down Expand Up @@ -174,6 +203,7 @@ describe('devupUIVitePlugin', () => {
const plugin = createPlugin({})
expect(plugin).toEqual({
name: 'devup-ui',
sharedDuringBuild: true,
config: expect.any(Function),
load: expect.any(Function),
watchChange: expect.any(Function),
Expand Down Expand Up @@ -572,6 +602,117 @@ describe('devupUIVitePlugin', () => {
expect(bundle['base.css'].source).toEqual('final complete sheet')
})

it('does not forward server css that the client already emits', async () => {
const plugin = createPlugin({})
getCssSpy.mockImplementation((fileNum: number | null) =>
fileNum === null ? 'base sheet' : 'file sheet',
)
const serverBundle = {
'base.css': { source: 'stale', name: 'devup-ui.css' },
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
'entry.js': {
name: 'entry',
viteMetadata: {
importedCss: new Set(['base.css', 'file.css', 'server-only.css']),
},
},
}
const clientBundle = {
'base.css': { source: 'stale', name: 'devup-ui.css' },
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
}

await plugin.generateBundle.call(
{ environment: { name: 'rsc', config: { consumer: 'server' } } },
{},
serverBundle,
)
await plugin.generateBundle.call(
{ environment: { name: 'client', config: { consumer: 'client' } } },
{},
clientBundle,
)

expect(serverBundle['base.css'].source).toEqual('base sheet')
expect(serverBundle['file.css'].source).toEqual('file sheet')
expect(clientBundle['base.css'].source).toEqual('base sheet')
expect(clientBundle['file.css'].source).toEqual('file sheet')
expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
new Set(['server-only.css']),
)
})

it('ignores no-write analysis bundles when tracking server css', async () => {
const plugin = createPlugin({})
const serverBundle = {
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
'entry.js': {
name: 'entry',
viteMetadata: { importedCss: new Set(['file.css']) },
},
}
await plugin.generateBundle.call(
{
environment: {
name: 'rsc',
config: { consumer: 'server', build: { write: false } },
},
},
{},
serverBundle,
)
const clientBundle = {
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
}

await plugin.generateBundle.call(
{ environment: { name: 'client', config: { consumer: 'client' } } },
{},
clientBundle,
)

expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
new Set(['file.css']),
)
})

it('keeps server forwarding for a different output file name', async () => {
const plugin = createPlugin({})
const serverBundle = {
'devup-ui-3.server.css': {
source: 'stale',
name: 'devup-ui-3.css',
},
'entry.js': {
name: 'entry',
viteMetadata: {
importedCss: new Set(['devup-ui-3.server.css']),
},
},
}
await plugin.generateBundle.call(
{ environment: { name: 'rsc', config: { consumer: 'server' } } },
{},
serverBundle,
)
const clientBundle = {
'devup-ui-3.client.css': {
source: 'stale',
name: 'devup-ui-3.css',
},
}

await plugin.generateBundle.call(
{ environment: { name: 'client', config: { consumer: 'client' } } },
{},
clientBundle,
)

expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
new Set(['devup-ui-3.server.css']),
)
})

it('resolves a stable id during build', async () => {
const plugin = createPlugin({})
await plugin.configResolved({ command: 'build' })
Expand Down
41 changes: 38 additions & 3 deletions packages/vite-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ interface ConfigHookMeta {
rolldownVersion?: string
}

interface ViteOutputWithMetadata {
viteMetadata?: {
importedCss?: Set<string>
}
}

/**
* Vite merges a plugin's `config()` result over the user's, replacing function
* values outright, so returning a bare `manualChunks` silently drops one the
Expand Down Expand Up @@ -262,9 +268,14 @@ export function DevupUI({
}
const importAliases = mergeImportAliases(userImportAliases)
const cssMap = new Map()
let serverBundleToForward: Record<string, ViteOutputWithMetadata> | undefined
let isServe = false
return {
name: 'devup-ui',
// The WASM sheet and transform state are intentionally shared. Vite
// otherwise recreates this plugin for every environment build, which makes
// each environment independently emit the same CSS asset.
sharedDuringBuild: true,
async configResolved(config) {
isServe = config?.command === 'serve'
const projectRoot = config?.root ?? process.cwd()
Expand Down Expand Up @@ -455,7 +466,7 @@ export function DevupUI({
if (!rel.startsWith('./')) rel = `./${rel}`

const {
code: retCode,
code: extractedCode,
css = '',
map,
cssFile,
Expand Down Expand Up @@ -494,12 +505,14 @@ export function DevupUI({
}
await Promise.all(promises)
return {
code: retCode,
code: extractedCode,
map,
}
},
async generateBundle(_options, bundle) {
if (!extractCss) return
const writesOutput = this.environment?.config.build?.write !== false
const cssFiles = new Set<string>()

// `load` can only snapshot the sheet as it stood when the module was
// pulled in, and module order varies per build, so the emitted asset was
Expand All @@ -512,7 +525,29 @@ export function DevupUI({
const cssName = getDevupCssChunkName(asset.name)
if (!cssName) continue
if (!('source' in asset)) continue
asset.source = getCss(getFileNumByFilename(cssName), false)
const source = getCss(getFileNumByFilename(cssName), false)
asset.source = source
cssFiles.add(file)
}

const environment = this.environment
if (!environment || !writesOutput) return
if (environment.config.consumer === 'client' && serverBundleToForward) {
// @vitejs/plugin-rsc forwards every CSS file referenced by the RSC
// bundle into the client bundle. Files the client already emitted are
// registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles'
// imports and client metadata intact, but remove overlaps from the RSC
// forwarding set before its later generateBundle hook reads it.
for (const output of Object.values(serverBundleToForward)) {
for (const file of cssFiles) {
output.viteMetadata?.importedCss?.delete(file)
}
}
} else if (environment.config.consumer === 'server') {
serverBundleToForward = bundle as unknown as Record<
string,
ViteOutputWithMetadata
>
}
},
}
Expand Down
Loading