diff --git a/.changepacks/changepack_log_kQbMx_I1A9iUKgf2hMJkR.json b/.changepacks/changepack_log_kQbMx_I1A9iUKgf2hMJkR.json new file mode 100644 index 00000000..9d757ca5 --- /dev/null +++ b/.changepacks/changepack_log_kQbMx_I1A9iUKgf2hMJkR.json @@ -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" +} diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index d7b8e6d1..cb658298 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -43,6 +43,7 @@ interface ViteConfig { interface ViteTestPlugin { name: string + sharedDuringBuild: true enforce: 'pre' apply: () => boolean config: ( @@ -66,10 +67,38 @@ interface ViteTestPlugin { timestamp: number }) => Promise 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, + bundle: Record< + string, + { + source?: string + name: string + viteMetadata?: { importedCss?: Set } + } + >, ) => Promise resolveId: (source: string, importer?: string) => string | undefined } @@ -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), @@ -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' }) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index b2022ff1..546dd81b 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -113,6 +113,12 @@ interface ConfigHookMeta { rolldownVersion?: string } +interface ViteOutputWithMetadata { + viteMetadata?: { + importedCss?: Set + } +} + /** * Vite merges a plugin's `config()` result over the user's, replacing function * values outright, so returning a bare `manualChunks` silently drops one the @@ -262,9 +268,14 @@ export function DevupUI({ } const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() + let serverBundleToForward: Record | 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() @@ -455,7 +466,7 @@ export function DevupUI({ if (!rel.startsWith('./')) rel = `./${rel}` const { - code: retCode, + code: extractedCode, css = '', map, cssFile, @@ -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() // `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 @@ -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 + > } }, }