Skip to content

Commit b6148d4

Browse files
authored
Merge pull request #653 from dev-five-git/owjs3901/fix-css-chunk-reemit
fix(vite-plugin): avoid duplicate environment CSS chunks
2 parents d639def + 381c864 commit b6148d4

3 files changed

Lines changed: 188 additions & 5 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"changes": {
3+
"packages/vite-plugin/package.json": "Patch"
4+
},
5+
"note": "Avoid re-emitting identical Devup CSS chunks across Vite environment builds",
6+
"date": "2026-08-30T10:18:56.558349Z"
7+
}

packages/vite-plugin/src/__tests__/plugin.test.ts

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface ViteConfig {
4343

4444
interface ViteTestPlugin {
4545
name: string
46+
sharedDuringBuild: true
4647
enforce: 'pre'
4748
apply: () => boolean
4849
config: (
@@ -66,10 +67,38 @@ interface ViteTestPlugin {
6667
timestamp: number
6768
}) => Promise<unknown[] | undefined>
6869
load: (id: string) => string | undefined
69-
transform: (code: string, id: string) => Promise<{ code: string } | undefined>
70+
transform: (
71+
this: {
72+
environment?: {
73+
name: string
74+
config: {
75+
consumer: 'client' | 'server'
76+
build?: { write?: boolean }
77+
}
78+
}
79+
},
80+
code: string,
81+
id: string,
82+
) => Promise<{ code: string } | undefined>
7083
generateBundle: (
84+
this: {
85+
environment?: {
86+
name: string
87+
config: {
88+
consumer: 'client' | 'server'
89+
build?: { write?: boolean }
90+
}
91+
}
92+
},
7193
options: object,
72-
bundle: Record<string, { source: string; name: string }>,
94+
bundle: Record<
95+
string,
96+
{
97+
source?: string
98+
name: string
99+
viteMetadata?: { importedCss?: Set<string> }
100+
}
101+
>,
73102
) => Promise<void>
74103
resolveId: (source: string, importer?: string) => string | undefined
75104
}
@@ -174,6 +203,7 @@ describe('devupUIVitePlugin', () => {
174203
const plugin = createPlugin({})
175204
expect(plugin).toEqual({
176205
name: 'devup-ui',
206+
sharedDuringBuild: true,
177207
config: expect.any(Function),
178208
load: expect.any(Function),
179209
watchChange: expect.any(Function),
@@ -572,6 +602,117 @@ describe('devupUIVitePlugin', () => {
572602
expect(bundle['base.css'].source).toEqual('final complete sheet')
573603
})
574604

605+
it('does not forward server css that the client already emits', async () => {
606+
const plugin = createPlugin({})
607+
getCssSpy.mockImplementation((fileNum: number | null) =>
608+
fileNum === null ? 'base sheet' : 'file sheet',
609+
)
610+
const serverBundle = {
611+
'base.css': { source: 'stale', name: 'devup-ui.css' },
612+
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
613+
'entry.js': {
614+
name: 'entry',
615+
viteMetadata: {
616+
importedCss: new Set(['base.css', 'file.css', 'server-only.css']),
617+
},
618+
},
619+
}
620+
const clientBundle = {
621+
'base.css': { source: 'stale', name: 'devup-ui.css' },
622+
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
623+
}
624+
625+
await plugin.generateBundle.call(
626+
{ environment: { name: 'rsc', config: { consumer: 'server' } } },
627+
{},
628+
serverBundle,
629+
)
630+
await plugin.generateBundle.call(
631+
{ environment: { name: 'client', config: { consumer: 'client' } } },
632+
{},
633+
clientBundle,
634+
)
635+
636+
expect(serverBundle['base.css'].source).toEqual('base sheet')
637+
expect(serverBundle['file.css'].source).toEqual('file sheet')
638+
expect(clientBundle['base.css'].source).toEqual('base sheet')
639+
expect(clientBundle['file.css'].source).toEqual('file sheet')
640+
expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
641+
new Set(['server-only.css']),
642+
)
643+
})
644+
645+
it('ignores no-write analysis bundles when tracking server css', async () => {
646+
const plugin = createPlugin({})
647+
const serverBundle = {
648+
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
649+
'entry.js': {
650+
name: 'entry',
651+
viteMetadata: { importedCss: new Set(['file.css']) },
652+
},
653+
}
654+
await plugin.generateBundle.call(
655+
{
656+
environment: {
657+
name: 'rsc',
658+
config: { consumer: 'server', build: { write: false } },
659+
},
660+
},
661+
{},
662+
serverBundle,
663+
)
664+
const clientBundle = {
665+
'file.css': { source: 'stale', name: 'devup-ui-3.css' },
666+
}
667+
668+
await plugin.generateBundle.call(
669+
{ environment: { name: 'client', config: { consumer: 'client' } } },
670+
{},
671+
clientBundle,
672+
)
673+
674+
expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
675+
new Set(['file.css']),
676+
)
677+
})
678+
679+
it('keeps server forwarding for a different output file name', async () => {
680+
const plugin = createPlugin({})
681+
const serverBundle = {
682+
'devup-ui-3.server.css': {
683+
source: 'stale',
684+
name: 'devup-ui-3.css',
685+
},
686+
'entry.js': {
687+
name: 'entry',
688+
viteMetadata: {
689+
importedCss: new Set(['devup-ui-3.server.css']),
690+
},
691+
},
692+
}
693+
await plugin.generateBundle.call(
694+
{ environment: { name: 'rsc', config: { consumer: 'server' } } },
695+
{},
696+
serverBundle,
697+
)
698+
const clientBundle = {
699+
'devup-ui-3.client.css': {
700+
source: 'stale',
701+
name: 'devup-ui-3.css',
702+
},
703+
}
704+
705+
await plugin.generateBundle.call(
706+
{ environment: { name: 'client', config: { consumer: 'client' } } },
707+
{},
708+
clientBundle,
709+
)
710+
711+
expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual(
712+
new Set(['devup-ui-3.server.css']),
713+
)
714+
})
715+
575716
it('resolves a stable id during build', async () => {
576717
const plugin = createPlugin({})
577718
await plugin.configResolved({ command: 'build' })

packages/vite-plugin/src/plugin.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ interface ConfigHookMeta {
113113
rolldownVersion?: string
114114
}
115115

116+
interface ViteOutputWithMetadata {
117+
viteMetadata?: {
118+
importedCss?: Set<string>
119+
}
120+
}
121+
116122
/**
117123
* Vite merges a plugin's `config()` result over the user's, replacing function
118124
* values outright, so returning a bare `manualChunks` silently drops one the
@@ -262,9 +268,14 @@ export function DevupUI({
262268
}
263269
const importAliases = mergeImportAliases(userImportAliases)
264270
const cssMap = new Map()
271+
let serverBundleToForward: Record<string, ViteOutputWithMetadata> | undefined
265272
let isServe = false
266273
return {
267274
name: 'devup-ui',
275+
// The WASM sheet and transform state are intentionally shared. Vite
276+
// otherwise recreates this plugin for every environment build, which makes
277+
// each environment independently emit the same CSS asset.
278+
sharedDuringBuild: true,
268279
async configResolved(config) {
269280
isServe = config?.command === 'serve'
270281
const projectRoot = config?.root ?? process.cwd()
@@ -455,7 +466,7 @@ export function DevupUI({
455466
if (!rel.startsWith('./')) rel = `./${rel}`
456467

457468
const {
458-
code: retCode,
469+
code: extractedCode,
459470
css = '',
460471
map,
461472
cssFile,
@@ -494,12 +505,14 @@ export function DevupUI({
494505
}
495506
await Promise.all(promises)
496507
return {
497-
code: retCode,
508+
code: extractedCode,
498509
map,
499510
}
500511
},
501512
async generateBundle(_options, bundle) {
502513
if (!extractCss) return
514+
const writesOutput = this.environment?.config.build?.write !== false
515+
const cssFiles = new Set<string>()
503516

504517
// `load` can only snapshot the sheet as it stood when the module was
505518
// pulled in, and module order varies per build, so the emitted asset was
@@ -512,7 +525,29 @@ export function DevupUI({
512525
const cssName = getDevupCssChunkName(asset.name)
513526
if (!cssName) continue
514527
if (!('source' in asset)) continue
515-
asset.source = getCss(getFileNumByFilename(cssName), false)
528+
const source = getCss(getFileNumByFilename(cssName), false)
529+
asset.source = source
530+
cssFiles.add(file)
531+
}
532+
533+
const environment = this.environment
534+
if (!environment || !writesOutput) return
535+
if (environment.config.consumer === 'client' && serverBundleToForward) {
536+
// @vitejs/plugin-rsc forwards every CSS file referenced by the RSC
537+
// bundle into the client bundle. Files the client already emitted are
538+
// registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles'
539+
// imports and client metadata intact, but remove overlaps from the RSC
540+
// forwarding set before its later generateBundle hook reads it.
541+
for (const output of Object.values(serverBundleToForward)) {
542+
for (const file of cssFiles) {
543+
output.viteMetadata?.importedCss?.delete(file)
544+
}
545+
}
546+
} else if (environment.config.consumer === 'server') {
547+
serverBundleToForward = bundle as unknown as Record<
548+
string,
549+
ViteOutputWithMetadata
550+
>
516551
}
517552
},
518553
}

0 commit comments

Comments
 (0)