From 0ee8e4d8a9908353cc3ddbb7a07e2df470524d15 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 14 Feb 2026 22:54:55 -0800 Subject: [PATCH] feat: add CSS injection option for library mode (#1579) Add `build.lib.cssInject` option that injects CSS into the JavaScript output via a runtime styleInject helper instead of emitting a separate CSS file. This allows library consumers to import a single JS file without needing to separately import CSS. Also adds `cjs` format support to `injectInlinedCSS`. --- .../src/node/__tests__/plugins/css.spec.ts | 22 +++++++++++ packages/vite/src/node/build.ts | 9 +++++ packages/vite/src/node/plugins/css.ts | 37 ++++++++++++++----- playground/lib/__tests__/lib.spec.ts | 12 ++++++ playground/lib/__tests__/serve.ts | 6 +++ playground/lib/vite.css-inject.config.js | 13 +++++++ 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 playground/lib/vite.css-inject.config.js diff --git a/packages/vite/src/node/__tests__/plugins/css.spec.ts b/packages/vite/src/node/__tests__/plugins/css.spec.ts index e6446e5e9caf38..9279e8f166823f 100644 --- a/packages/vite/src/node/__tests__/plugins/css.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/css.spec.ts @@ -793,4 +793,26 @@ exports.foo = foo; })();" `) }) + + test('should inject helper for cjs format', async () => { + const result = getInlinedCSSInjectedCode( + `"use strict"; + +//#region src/index.js +const foo = "foo"; + +//#endregion +exports.foo = foo;`, + 'cjs', + ) + expect(result).toMatchInlineSnapshot(` + "injectCSS();"use strict"; + + //#region src/index.js + const foo = "foo"; + + //#endregion + exports.foo = foo;" + `) + }) }) diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 761b9dd5fac23d..474302b8142840 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -331,6 +331,15 @@ export interface LibraryOptions { * back to the name option of the project package.json. */ cssFileName?: string + /** + * Whether to inject CSS into the JavaScript output by appending it to the + * document head at runtime, instead of emitting a separate CSS file. + * This is useful when you want consumers to import a single JS file + * without needing to separately import CSS. + * Note: this makes the library incompatible with SSR environments. + * @default false + */ + cssInject?: boolean } export type LibraryFormats = 'es' | 'cjs' | 'umd' | 'iife' // | 'system' diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index b504c1c697c8c5..e8e38c6ea6e6d6 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -1033,14 +1033,33 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { if (extractedCss) { hasEmitted = true extractedCss = await finalizeCss(extractedCss, config) - this.emitFile({ - name: getCssBundleName(), - type: 'asset', - source: extractedCss, - // this file is an implicit entry point, use defaultCssBundleName as the original file name - // this name is also used as a key in the manifest - originalFileName: defaultCssBundleName, - }) + + const libOptions = config.build.lib + if (libOptions && libOptions.cssInject) { + // Inject CSS into the JS entry chunk(s) via a styleInject helper + extractedCss = extractedCss + .replace(viteHashUpdateMarkerRE, '') + .trim() + const cssString = JSON.stringify(extractedCss) + const injectCode = `(function(){try{var d=document,s=d.createElement("style");s.appendChild(d.createTextNode(${cssString}));d.head.appendChild(s)}catch(e){console.error("vite-css-inject",e)}})(); +` + for (const file of Object.values(bundle)) { + if (file.type === 'chunk' && file.isEntry) { + const s = new MagicString(file.code) + injectInlinedCSS(s, this, file.code, opts.format, injectCode) + file.code = s.toString() + } + } + } else { + this.emitFile({ + name: getCssBundleName(), + type: 'asset', + source: extractedCss, + // this file is an implicit entry point, use defaultCssBundleName as the original file name + // this name is also used as a key in the manifest + originalFileName: defaultCssBundleName, + }) + } } } @@ -1150,7 +1169,7 @@ export function injectInlinedCSS( ctx.error('Injection point for inlined CSS not found') } injectionPoint = m.index + m[0].length - } else if (format === 'es') { + } else if (format === 'es' || format === 'cjs') { // legacy build if (code.startsWith('#!')) { let secondLinePos = code.indexOf('\n') diff --git a/playground/lib/__tests__/lib.spec.ts b/playground/lib/__tests__/lib.spec.ts index e0371cb358d4d0..d40b57f1b0c316 100644 --- a/playground/lib/__tests__/lib.spec.ts +++ b/playground/lib/__tests__/lib.spec.ts @@ -120,6 +120,18 @@ describe.runIf(isBuild)('build', () => { expect(cjs2).toContain('css-entry-2') }) + test('single entry with css inject', () => { + const js = readFile('dist/css-inject/test-my-lib.js') + const umd = readFile('dist/css-inject/test-my-lib.umd.cjs') + // CSS should be injected into JS, not emitted as a separate file + expect(js).toMatch('entry-1.css') + expect(js).toMatch('createElement') + expect(umd).toMatch('entry-1.css') + expect(umd).toMatch('createElement') + // No separate CSS file should be emitted + expect(() => readFile('dist/css-inject/test-my-lib.css')).toThrow() + }) + test('multi entry with css and code split', () => { const css1 = readFile('dist/css-code-split/css-entry-1.css') const css2 = readFile('dist/css-code-split/css-entry-2.css') diff --git a/playground/lib/__tests__/serve.ts b/playground/lib/__tests__/serve.ts index 962c67dc2fa1ef..4ad87674d94d11 100644 --- a/playground/lib/__tests__/serve.ts +++ b/playground/lib/__tests__/serve.ts @@ -113,6 +113,12 @@ export async function serve(): Promise<{ close(): Promise }> { configFile: path.resolve(dirname, '../vite.terser.config.js'), }) + await build({ + root: rootDir, + logLevel: 'warn', // output esbuild warns + configFile: path.resolve(dirname, '../vite.css-inject.config.js'), + }) + // start static file server const serve = sirv(path.resolve(rootDir, 'dist')) const httpServer = http.createServer((req, res) => { diff --git a/playground/lib/vite.css-inject.config.js b/playground/lib/vite.css-inject.config.js new file mode 100644 index 00000000000000..3efcb72246b343 --- /dev/null +++ b/playground/lib/vite.css-inject.config.js @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: fileURLToPath(new URL('src/css-entry-1.js', import.meta.url)), + name: 'css-inject', + cssInject: true, + }, + outDir: 'dist/css-inject', + }, +})