Skip to content

fix: remove the BOM from the compiled CSS - #1337

Merged
alexander-akait merged 2 commits into
mainfrom
claude/fix-bom-compressed-output
Aug 30, 2026
Merged

fix: remove the BOM from the compiled CSS#1337
alexander-akait merged 2 commits into
mainfrom
claude/fix-bom-compressed-output

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Aug 29, 2026

Copy link
Copy Markdown
Member

Closes #1335

The problem

dart-sass prepends a byte order mark (U+FEFF) to the compiled CSS when the charset option is enabled (the default), the style option is compressed — which the loader sets automatically in the production mode — and the CSS contains non-ASCII characters. In expanded style it emits @charset "UTF-8"; instead, which is harmless.

A BOM is only meaningful at the very beginning of a file, but the loader result is just a string for webpack. css-loader hoists @import at-rules above the module content, so the BOM ends up in the middle of the generated CSS, where the browser reads it as part of the following selector (:root in the reported case) and breaks that rule.

Reproducing the issue's setup before this change (css-loader 7.1.4 + sass, production):

___CSS_LOADER_EXPORT___.push([module.id, "@import url(https://fonts.googleapis.com/...);"]);
// Module
___CSS_LOADER_EXPORT___.push([module.id, `body{background-color:skyblue}...`]);

Notes from investigating it:

  • sass-embedded does not emit the BOM, so only users on sass are affected.
  • postcss preserves the BOM, which is why it survived css-loader untouched.

The fix

removeBOM() strips a leading BOM from the compiled CSS before the loader hands it to webpack.

Sass counts the BOM as the first column of the first line, so removing it shifts every mapping of that line. The first segment's generated column is decremented by one — the segments after it are relative to the previous one and need no change. Verified against a reference compile of the same stylesheet without non-ASCII characters: the adjusted mappings match it exactly (CAAAAAAA).

Relation to the upstream fixes

Webpack has since fixed both halves of this: webpack/webpack#21857 removes a BOM a loader produced from a string result, and webpack/webpack#21861 keeps the accompanying source map in sync with it. Neither is released yet — the latest published webpack is 5.110.1 — and rspack still passes a string result through untouched (checked on 2.2.1).

Measured end to end with dart-sass, mode: "production", devtool: "source-map" and the built-in CSS support, comparing this branch against main:

CSS keeps the BOM mapping for the p rule
webpack main (#21857 + #21861), without this change no line 1, column 0 — correct
webpack main, with this change no line 1, column 0 — correct
released 5.110.1, without this change no line 1, column 1 — off by one
released 5.110.1, with this change no line 1, column 0 — correct

The two compose: this loader strips first, so webpack finds no BOM, does not strip again and does not shift the map a second time. Once the minimum supported webpack carries both fixes and rspack handles a string result, this can be dropped — a comment on removeBOM() records that.

One behavioral difference worth knowing: for type: "asset/resource", where the result is written out as a standalone file, webpack keeps the BOM (removeBOMFromResult is only tapped for javascript/* and css/* module types) while this loader removes it. A BOM at offset 0 of an emitted file is legitimate, so if parity matters more than consistency, the strip could be skipped for asset modules.

Tests

  • loader.test.js: "should remove the BOM from the compiled CSS" — compiles the charset-utf-8 fixture with style: "compressed" and asserts the result does not start with a BOM.
  • sourceMap-options.test.js: "should generate source maps for the compressed style without the removed BOM" — pins the shifted mappings alongside the CSS.
  • The two existing "compressed output style in production" tests compare the loader output against a raw Sass compile, so they now strip the BOM from that reference; their snapshots lose the leading ``, which is the fix itself.

One thing worth flagging: in the new source-map snapshots, sass-embedded records CAEA where dart-sass records AAEA. sass-embedded emits no BOM but still maps the first segment to generated column 1, so its map is off by one upstream — unrelated to the BOM, and the loader cannot tell that apart from a legitimate column-1 mapping, so it is left alone here.

Verification

  • Full npm run test:base passes.
  • npm run lint (eslint, cspell, tsc --noEmit, prettier) is clean.
  • types/utils.d.ts regenerated via npm run build:types; a patch changeset is included.

🤖 Generated with Claude Code

https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky

`dart-sass` prepends a byte order mark to the compiled CSS when the
`charset` option is enabled (by default), the `style` option is
`compressed` (which the loader sets automatically in the `production`
mode) and the CSS contains non ASCII characters.

A BOM is only meaningful at the very beginning of a file, but the loader
result is just a string for webpack. Tools like `css-loader` move
`@import` at-rules above it, so the BOM ended up in the middle of the
generated CSS, where browsers read it as a part of the following
selector and broke that rule.

Sass counts the BOM as the first column of the first line, so the
generated column of the first mapping is shifted by one when it is
removed - the segments after it are relative to the previous one and
stay untouched.

Closes #1335

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky
@changeset-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5146bf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
sass-loader Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.26168% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.62%. Comparing base (a28ab73) to head (f5146bf).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/utils.js 96.11% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1337      +/-   ##
==========================================
- Coverage   96.67%   96.62%   -0.05%     
==========================================
  Files           2        2              
  Lines         901     1007     +106     
==========================================
+ Hits          871      973     +102     
- Misses         30       34       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Webpack removes a loader-produced BOM since #21857 and adjusts the source
map with it since #21861. Record when this can be dropped: once the
minimum supported webpack carries both and `rspack` handles a string
result too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky
@alexander-akait
alexander-akait merged commit 72640db into main Aug 30, 2026
14 of 16 checks passed
@alexander-akait
alexander-akait deleted the claude/fix-bom-compressed-output branch August 30, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte Order Marker (BOM) inserted after external @import affects syntax

1 participant