Skip to content

future.faster silently drops a CSS rule when a Sass-generated BOM lands mid-bundle #12358

Description

@doxxx93

Have you read the Contributing Guidelines on issues?

Prerequisites

  • I'm using the latest version of Docusaurus.
  • I have tried the npm run clear or yarn clear command.
  • I have tried rm -rf node_modules yarn.lock package-lock.json and re-installing packages.
  • I have read the console error message carefully (if applicable).

Description

With future.faster enabled, a Sass-generated UTF-8 BOM ends up in the middle of the concatenated CSS bundle, and LightningCssMinimizerRspackPlugin silently deletes the rule that follows it. The build exits 0 with no warning or error, and the asset referenced by the deleted rule is still emitted, so nothing looks wrong in build/.

On my site the casualty was the upright @font-face of a self-hosted code font. Only the font-style: italic face survived, and since CSS font matching falls back to an italic face when the family has no normal one, every code block on the production site rendered in italic. It shipped that way for two weeks, because the site builds clean and the .woff2 is sitting right there in build/assets/fonts/.

The chain, each step verified on my site:

  1. Dart Sass prepends a UTF-8 BOM to compressed output whenever it contains a non-ASCII character. Mine has one content: "▸" in a ::before, and that alone is enough — sass --style=compressed emits ef bb bf before the first rule, --no-charset does not, and replacing the with an ASCII character makes the BOM disappear.
  2. Until postcss 8.5.23 a postcss pass silently stripped that BOM. postcss 8.5.24 changed to "Preserve the BOM after the processing", so it now survives into the bundle. My site was on postcss 8.5.10 and fine; a lockfile migration pulled 8.5.25 and the code font went italic that day.
  3. The Sass chunk is concatenated after the Infima CSS, so the BOM lands mid-file rather than at offset 0. In my pre-minification bundle it sits at byte 79316, as 7d 0a 0a ef bb bf 40 66 6f 6e 74 2d 66 61 63 65}\n\n<BOM>@font-face.
  4. The minifier drops that @font-face and the BOM together. The shipped CSS contains neither. A --no-minify build of the same commit contains both rules, which is what identifies the minifier as the step responsible.

Step 3 is where valid inputs turn into invalid output, and it is not really a Sass problem. Per CSS Syntax §3.2 a BOM is removed during decoding, so a standalone stylesheet that starts with one is perfectly valid. Concatenating that same stylesheet into position 79316 of a bundle is what makes the BOM meaningful to the tokenizer — it stops being an encoding marker and becomes a U+FEFF ident character sitting in front of an at-rule. The CSS extractor (CssExtractRspackPlugin on the faster path, mini-css-extract-plugin otherwise) concatenates module contents verbatim, so two valid modules produce one invalid bundle. I could not find an existing BOM issue on either repo.

The related parse-side report is parcel-bundler/lightningcss#338 (open since 2022), though the fix proposed there — stripping a leading BOM — would not help this case, since the BOM is not at offset 0.

What makes it a Docusaurus-side problem is the failure mode rather than the parse itself. Running the JS lightningcss package (1.33.0) over my exact pre-minification bundle throws:

Unexpected token AtKeyword("font-face") { line: 3097, column: 2 }

A thrown error is survivable — someone sees it and fixes it. But future.faster selects the Rspack bundler, so the minimizer is rspack.LightningCssMinimizerRspackPlugin (packages/bundler/src/minification.ts), not that JS package. On identical input it neither throws nor warns. It removes the rule and the build reports success. I confirmed the JS package is never called: I patched lightningcss.transform via NODE_OPTIONS=--require for a full build and it was not invoked once, while the rule still vanished from the output.

So the two Lightning CSS integrations Docusaurus ships disagree on the same input, and the default one (faster) is the silent one. A site owner gets a clean build and a wrong page, with nothing connecting the two.

This is not docusaurus-plugin-sass doing something unusual. Any site whose compiled CSS contains one non-ASCII character is exposed once faster is on, and the trigger was a transitive postcss bump, not a change to anyone's stylesheet.

Reproducible demo

No hosted repro — it needs a production build to observe. The recipe below reproduces from a clean create-docusaurus in a couple of minutes.

Steps to reproduce

  1. npx create-docusaurus@latest my-site classic --typescript
  2. npm i -D docusaurus-plugin-sass sass, add "docusaurus-plugin-sass" to plugins, set future: {faster: true}.
  3. Rename src/css/custom.css to custom.scss, point theme.customCss at it, and make its first rules a font family, with a non-ASCII glyph anywhere in the file:
@font-face {
  font-family: 'Test Font';
  src: url('/fonts/test.woff2') format('woff2');
  font-style: normal;
}
@font-face {
  font-family: 'Test Font';
  src: url('/fonts/test-italic.woff2') format('woff2');
  font-style: italic;
}

.some-class::before { content: ""; }  // any non-ASCII char — this is the trigger
  1. npm run build
  2. grep -o "@font-face{[^}]*}" build/assets/css/*.css — only the italic one is there.
  3. npm run build -- --no-minify and grep again — both are there.

Expected behavior

Both @font-face rules survive minification. Failing that, a build that cannot parse part of its own CSS says so, instead of dropping a rule and reporting success.

Actual behavior

Only the second @font-face is in the built CSS:

@font-face{font-family:Test Font;src:url(/assets/fonts/test-italic-<hash>.woff2)format("woff2");font-style:italic}

Exit code 0, no warnings. build/assets/fonts/ still contains both files; only the url() reference to the first is gone.

Suggested fix: strip  from CSS assets before they reach the minimizer, e.g. a compilation.hooks.processAssets pass at a stage before minimize. A leading-only strip is not enough — the BOM has to be removed wherever it lands after concatenation. This is bundler-agnostic, so it covers both the faster and the webpack path, and it also covers a hand-authored .css file that happens to be saved with a BOM.

I am deliberately not suggesting sassOptions: {charset: false} as a project default. It is what I used on my own site — ["docusaurus-plugin-sass", {sassOptions: {charset: false}}], which works because the plugin forwards options straight to sass-loader — but sass-loader strongly discourages it, and it is only safe for me because my host sends content-type: text/css; charset=utf-8. It is a workaround for a site owner who has already diagnosed the problem, not a default for everyone. I only found it by diffing a --no-minify build against a normal one.

I searched open and closed issues for charset, lightningcss, font-face, and minify css and did not find this reported.

Your environment

  • Docusaurus 3.10.2, future: {faster: true, v4: true}
  • @docusaurus/faster 3.10.2, @rspack/core 1.7.12, lightningcss (JS) 1.33.0
  • docusaurus-plugin-sass 0.2.6, sass-loader 16.0.8, sass 1.102.0
  • postcss 8.5.25
  • Node 24.14.0, bun 1.3.14, macOS (darwin arm64)

Self-service

  • I'd be willing to fix this bug myself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions