Summary
Since postcss 8.5.24 ("Preserve the BOM when stringifying"), a BOM at the start of a loader-generated CSS module survives css-loader's postcss round-trip. When several CSS modules are concatenated into one chunk, that BOM ends up in the middle of the file, glued to the next selector — which makes the rule invalid and silently drops it.
dart-sass prepends U+FEFF to its output whenever style: "compressed" (the production default) and the output contains non-ASCII; with expanded it emits @charset "UTF-8" instead. So any Sass module with, say, a © in a preserved /*! … */ banner or a non-ASCII content: value produces such a module.
Minimal reproduction
src/index.js
import './first.css';
import './second.scss';
src/first.css
src/second.scss
:root{--brand:#0d6efd}
.second{color:var(--brand);content:"©"}
webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: './src/index.js',
output: { path: __dirname + '/dist', filename: 'bundle.js', clean: true },
optimization: { minimize: false },
module: {
rules: [
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{ loader: 'sass-loader', options: { sassOptions: { style: 'compressed' } } },
],
},
],
},
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
};
Actual
dist/styles.css:
.first{color:red}
:root{--brand:#0d6efd}.second{color:var(--brand);content:"©"}
In the browser the rule is present but its selectorText is :root, so it matches nothing: --brand resolves to "" and .second falls back to the initial color.
Expected
No BOM re-emitted for a module that is a fragment, not a standalone file — the behaviour up to 8.5.22.
| postcss |
BOMs in the emitted chunk |
| 8.5.22 |
0 |
| 8.5.24 |
1 |
Reading a .css file from disk is not affected — css-loader already handles that. Only loader-generated content is.
Why this matters
In a real Bootstrap 5 project this dropped the entire :root,[data-bs-theme=light] block — ~550 custom properties including fonts, colors and --bs-border-* — so the whole site rendered unstyled. The same stray BOM additionally crashed cssnano's postcss-merge-rules when it landed in front of a /*! banner containing a comma.
Suggested fix
Strip a leading BOM from the module source before handing it to postcss (or from the stringified result). css-loader's output is a fragment; postcss's change is reasonable for the single-file round-trip, and the fragment case is exactly where it breaks.
Workaround
sassOptions: { charset: false } in sass-loader.
Versions
css-loader 7.1.4 · postcss 8.5.24 · sass 1.94.3 · sass-loader 16.0.8 · mini-css-extract-plugin 2.9.x · webpack 5.109.1 · Node 20
Summary
Since postcss 8.5.24 ("Preserve the BOM when stringifying"), a BOM at the start of a loader-generated CSS module survives css-loader's postcss round-trip. When several CSS modules are concatenated into one chunk, that BOM ends up in the middle of the file, glued to the next selector — which makes the rule invalid and silently drops it.
dart-sass prepends U+FEFF to its output whenever
style: "compressed"(the production default) and the output contains non-ASCII; withexpandedit emits@charset "UTF-8"instead. So any Sass module with, say, a©in a preserved/*! … */banner or a non-ASCIIcontent:value produces such a module.Minimal reproduction
src/index.jssrc/first.csssrc/second.scsswebpack.config.jsActual
dist/styles.css:In the browser the rule is present but its
selectorTextis:root, so it matches nothing:--brandresolves to""and.secondfalls back to the initial color.Expected
No BOM re-emitted for a module that is a fragment, not a standalone file — the behaviour up to 8.5.22.
Reading a
.cssfile from disk is not affected — css-loader already handles that. Only loader-generated content is.Why this matters
In a real Bootstrap 5 project this dropped the entire
:root,[data-bs-theme=light]block — ~550 custom properties including fonts, colors and--bs-border-*— so the whole site rendered unstyled. The same stray BOM additionally crashed cssnano'spostcss-merge-ruleswhen it landed in front of a/*!banner containing a comma.Suggested fix
Strip a leading BOM from the module source before handing it to postcss (or from the stringified result). css-loader's output is a fragment; postcss's change is reasonable for the single-file round-trip, and the fragment case is exactly where it breaks.
Workaround
sassOptions: { charset: false }in sass-loader.Versions
css-loader 7.1.4 · postcss 8.5.24 · sass 1.94.3 · sass-loader 16.0.8 · mini-css-extract-plugin 2.9.x · webpack 5.109.1 · Node 20