From a8b2c42778ab0da576e0488365eabd35456538ae Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 19:23:08 +0900 Subject: [PATCH 1/2] fix(vite-plugin): avoid duplicate environment css chunks --- .../changepack_log_kQbMx_I1A9iUKgf2hMJkR.json | 7 ++ .../vite-plugin/src/__tests__/plugin.test.ts | 79 ++++++++++++++++++- packages/vite-plugin/src/plugin.ts | 34 +++++++- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_kQbMx_I1A9iUKgf2hMJkR.json 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..28f9a7a9 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,8 +67,17 @@ 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' } } + }, + code: string, + id: string, + ) => Promise<{ code: string } | undefined> generateBundle: ( + this: { + environment?: { name: string; config: { consumer: 'client' | 'server' } } + }, options: object, bundle: Record, ) => Promise @@ -174,6 +184,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 +583,72 @@ describe('devupUIVitePlugin', () => { expect(bundle['base.css'].source).toEqual('final complete sheet') }) + it('omits client imports for css already finalized by a server build', 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' }, + } + await plugin.generateBundle.call( + { environment: { name: 'rsc', config: { consumer: 'server' } } }, + {}, + serverBundle, + ) + relativeSpy.mockReturnValue('./df/devup-ui') + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: [ + 'import "./df/devup-ui/devup-ui.css";', + 'import "./df/devup-ui/devup-ui-3.css";', + 'export const value = 1;', + '', + ].join('\n'), + css: 'file sheet', + cssFile: './df/devup-ui/devup-ui-3.css', + }), + ) + + const result = await plugin.transform.call( + { environment: { name: 'client', config: { consumer: 'client' } } }, + 'source', + '/src/file.tsx', + ) + + expect(serverBundle['base.css'].source).toEqual('base sheet') + expect(serverBundle['file.css'].source).toEqual('file sheet') + expect(result?.code).toEqual('export const value = 1;\n') + }) + + it('keeps a client-only css import that has no server sheet', async () => { + const plugin = createPlugin({}) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base sheet' : 'server file sheet', + ) + await plugin.generateBundle.call( + { environment: { name: 'rsc', config: { consumer: 'server' } } }, + {}, + { 'file.css': { source: 'stale', name: 'devup-ui-3.css' } }, + ) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ + code: 'import "./df/devup-ui/devup-ui-4.css";\n', + css: 'client-only sheet', + cssFile: './df/devup-ui/devup-ui-4.css', + }), + ) + + const result = await plugin.transform.call( + { environment: { name: 'client', config: { consumer: 'client' } } }, + 'source', + '/src/file.tsx', + ) + + expect(result?.code).toEqual('import "./df/devup-ui/devup-ui-4.css";\n') + }) + 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..7364499d 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -103,6 +103,10 @@ function getDevupCssChunkName(id: string): string | undefined { return DEVUP_CSS_FILE_RE.test(fileName) ? fileName : undefined } +function removeCssImport(code: string, id: string): string { + return code.replace(`import ${JSON.stringify(id)};\n`, '') +} + /** * Subset of the plugin context Vite binds to the `config` hook. Vite >= 6.1 * exposes `meta.viteVersion`; a Rolldown-powered Vite also exposes @@ -262,9 +266,14 @@ export function DevupUI({ } const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() + const emittedServerCssAssets = new Map() let isServe = false return { name: 'devup-ui', + // The WASM sheet and the emitted-asset ownership below 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 +464,7 @@ export function DevupUI({ if (!rel.startsWith('./')) rel = `./${rel}` const { - code: retCode, + code: extractedCode, css = '', map, cssFile, @@ -471,6 +480,16 @@ export function DevupUI({ false, importAliases, ) + let retCode = extractedCode + if (this.environment?.config.consumer === 'client') { + const baseCss = emittedServerCssAssets.get('devup-ui.css') + if (baseCss !== undefined && baseCss === getCss(null, false)) { + retCode = removeCssImport(retCode, `${rel}/devup-ui.css`) + } + if (cssFile && emittedServerCssAssets.has(basename(cssFile))) { + retCode = removeCssImport(retCode, cssFile) + } + } const promises: Promise[] = [] if (updatedBaseStyle) { @@ -512,7 +531,18 @@ 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 + + // RSC frameworks build the server environment first, then forward its + // CSS assets into the client bundle. Remember each finished server + // sheet so later client transforms can omit duplicate per-file imports + // (and a byte-identical base import) before Rolldown registers them. + const environment = this.environment + if (!environment) continue + if (environment.config.consumer === 'server') { + emittedServerCssAssets.set(cssName, source) + } } }, } From 381c864a192ec02d92cc0446b787eef56effa393 Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 19:49:07 +0900 Subject: [PATCH 2/2] fix(vite-plugin): preserve client css metadata --- .../vite-plugin/src/__tests__/plugin.test.ts | 138 +++++++++++++----- packages/vite-plugin/src/plugin.ts | 59 ++++---- 2 files changed, 133 insertions(+), 64 deletions(-) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 28f9a7a9..cb658298 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -69,17 +69,36 @@ interface ViteTestPlugin { load: (id: string) => string | undefined transform: ( this: { - environment?: { name: string; config: { consumer: 'client' | 'server' } } + 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' } } + 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 } @@ -583,7 +602,7 @@ describe('devupUIVitePlugin', () => { expect(bundle['base.css'].source).toEqual('final complete sheet') }) - it('omits client imports for css already finalized by a server build', async () => { + 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', @@ -591,62 +610,107 @@ describe('devupUIVitePlugin', () => { 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, ) - relativeSpy.mockReturnValue('./df/devup-ui') - codeExtractSpy.mockReturnValue( - createCodeExtractResult({ - code: [ - 'import "./df/devup-ui/devup-ui.css";', - 'import "./df/devup-ui/devup-ui-3.css";', - 'export const value = 1;', - '', - ].join('\n'), - css: 'file sheet', - cssFile: './df/devup-ui/devup-ui-3.css', - }), - ) - - const result = await plugin.transform.call( + await plugin.generateBundle.call( { environment: { name: 'client', config: { consumer: 'client' } } }, - 'source', - '/src/file.tsx', + {}, + clientBundle, ) expect(serverBundle['base.css'].source).toEqual('base sheet') expect(serverBundle['file.css'].source).toEqual('file sheet') - expect(result?.code).toEqual('export const value = 1;\n') + 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('keeps a client-only css import that has no server sheet', async () => { + it('ignores no-write analysis bundles when tracking server css', async () => { const plugin = createPlugin({}) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base sheet' : 'server file sheet', + 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: 'rsc', config: { consumer: 'server' } } }, + { environment: { name: 'client', config: { consumer: 'client' } } }, {}, - { 'file.css': { source: 'stale', name: 'devup-ui-3.css' } }, + clientBundle, ) - codeExtractSpy.mockReturnValue( - createCodeExtractResult({ - code: 'import "./df/devup-ui/devup-ui-4.css";\n', - css: 'client-only sheet', - cssFile: './df/devup-ui/devup-ui-4.css', - }), + + 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', + }, + } - const result = await plugin.transform.call( + await plugin.generateBundle.call( { environment: { name: 'client', config: { consumer: 'client' } } }, - 'source', - '/src/file.tsx', + {}, + clientBundle, ) - expect(result?.code).toEqual('import "./df/devup-ui/devup-ui-4.css";\n') + expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( + new Set(['devup-ui-3.server.css']), + ) }) it('resolves a stable id during build', async () => { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 7364499d..546dd81b 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -103,10 +103,6 @@ function getDevupCssChunkName(id: string): string | undefined { return DEVUP_CSS_FILE_RE.test(fileName) ? fileName : undefined } -function removeCssImport(code: string, id: string): string { - return code.replace(`import ${JSON.stringify(id)};\n`, '') -} - /** * Subset of the plugin context Vite binds to the `config` hook. Vite >= 6.1 * exposes `meta.viteVersion`; a Rolldown-powered Vite also exposes @@ -117,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 @@ -266,13 +268,13 @@ export function DevupUI({ } const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() - const emittedServerCssAssets = new Map() + let serverBundleToForward: Record | undefined let isServe = false return { name: 'devup-ui', - // The WASM sheet and the emitted-asset ownership below are intentionally - // shared. Vite otherwise recreates this plugin for every environment build, - // which makes each environment independently emit the same CSS asset. + // 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' @@ -480,16 +482,6 @@ export function DevupUI({ false, importAliases, ) - let retCode = extractedCode - if (this.environment?.config.consumer === 'client') { - const baseCss = emittedServerCssAssets.get('devup-ui.css') - if (baseCss !== undefined && baseCss === getCss(null, false)) { - retCode = removeCssImport(retCode, `${rel}/devup-ui.css`) - } - if (cssFile && emittedServerCssAssets.has(basename(cssFile))) { - retCode = removeCssImport(retCode, cssFile) - } - } const promises: Promise[] = [] if (updatedBaseStyle) { @@ -513,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 @@ -533,16 +527,27 @@ export function DevupUI({ if (!('source' in asset)) continue const source = getCss(getFileNumByFilename(cssName), false) asset.source = source + cssFiles.add(file) + } - // RSC frameworks build the server environment first, then forward its - // CSS assets into the client bundle. Remember each finished server - // sheet so later client transforms can omit duplicate per-file imports - // (and a byte-identical base import) before Rolldown registers them. - const environment = this.environment - if (!environment) continue - if (environment.config.consumer === 'server') { - emittedServerCssAssets.set(cssName, source) + 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 + > } }, }