diff --git a/packages/playground/css/__tests__/css.spec.ts b/packages/playground/css/__tests__/css.spec.ts index d3577176b4bb16..6ca00e760349b1 100644 --- a/packages/playground/css/__tests__/css.spec.ts +++ b/packages/playground/css/__tests__/css.spec.ts @@ -247,6 +247,15 @@ test('inline css modules', async () => { expect(css).toMatch(/\.inline-module__apply-color-inline___[\w-]{5}/) }) +if (isBuild) { + test('@charset hoist', async () => { + serverLogs.forEach((log) => { + // no warning from esbuild css minifier + expect(log).not.toMatch('"@charset" must be the first rule in the file') + }) + }) +} + test('@import dependency w/ style entry', async () => { expect(await getColor('.css-dep')).toBe('purple') }) diff --git a/packages/playground/css/charset.css b/packages/playground/css/charset.css new file mode 100644 index 00000000000000..5c42b279f8404c --- /dev/null +++ b/packages/playground/css/charset.css @@ -0,0 +1,5 @@ +@charset "utf-8"; + +.utf8 { + color: green; +} diff --git a/packages/playground/css/index.html b/packages/playground/css/index.html index dd496a8ffdce11..fef6a0be393748 100644 --- a/packages/playground/css/index.html +++ b/packages/playground/css/index.html @@ -96,6 +96,9 @@

CSS

Inline CSS module:


 
+  

CSS with @charset:

+

+
   

@import dependency w/ style enrtrypoints: this should be purple

diff --git a/packages/playground/css/main.js b/packages/playground/css/main.js index 564b3a56677dbd..0d03aafbf0ec7f 100644 --- a/packages/playground/css/main.js +++ b/packages/playground/css/main.js @@ -41,6 +41,9 @@ text( import inlineMod from './inline.module.css?inline' text('.modules-inline', inlineMod) +import charset from './charset.css' +text('.charset-css', charset) + import './dep.css' import './glob-dep.css' diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index dbe9e7d1dfa00f..adef254950c5e5 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -427,10 +427,10 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { return `./${path.posix.basename(filename)}` } }) - // only external @imports should exist at this point - and they need to - // be hoisted to the top of the CSS chunk per spec (#1845) - if (css.includes('@import')) { - css = await hoistAtImports(css) + // only external @imports and @charset should exist at this point + // hoist them to the top of the CSS chunk per spec (#1845 and #6333) + if (css.includes('@import') || css.includes('@charset')) { + css = await hoistAtRules(css) } if (minify && config.build.minify) { css = await minifyCSS(css, config) @@ -1109,27 +1109,33 @@ async function minifyCSS(css: string, config: ResolvedConfig) { // #1845 // CSS @import can only appear at top of the file. We need to hoist all @import // to top when multiple files are concatenated. -async function hoistAtImports(css: string) { +// #6333 +// CSS @charset must be the top-first in the file, hoist to top too +async function hoistAtRules(css: string) { const postcss = await import('postcss') - return (await postcss.default([AtImportHoistPlugin]).process(css)).css + return (await postcss.default([AtRuleHoistPlugin]).process(css)).css } -const AtImportHoistPlugin: PostCSS.PluginCreator = () => { +const AtRuleHoistPlugin: PostCSS.PluginCreator = () => { return { - postcssPlugin: 'vite-hoist-at-imports', + postcssPlugin: 'vite-hoist-at-rules', Once(root) { const imports: PostCSS.AtRule[] = [] + let charset: PostCSS.AtRule | undefined root.walkAtRules((rule) => { if (rule.name === 'import') { // record in reverse so that can simply prepend to preserve order imports.unshift(rule) + } else if (!charset && rule.name === 'charset') { + charset = rule } }) imports.forEach((i) => root.prepend(i)) + if (charset) root.prepend(charset) } } } -AtImportHoistPlugin.postcss = true +AtRuleHoistPlugin.postcss = true // Preprocessor support. This logic is largely replicated from @vue/compiler-sfc