fix: remove the BOM from the compiled CSS - #1337
Merged
Merged
Conversation
`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 detectedLatest commit: f5146bf The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1335
The problem
dart-sassprepends a byte order mark (U+FEFF) to the compiled CSS when thecharsetoption is enabled (the default), thestyleoption iscompressed— which the loader sets automatically in theproductionmode — and the CSS contains non-ASCII characters. Inexpandedstyle 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-loaderhoists@importat-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 (:rootin the reported case) and breaks that rule.Reproducing the issue's setup before this change (
css-loader7.1.4 +sass, production):Notes from investigating it:
sass-embeddeddoes not emit the BOM, so only users onsassare affected.postcsspreserves the BOM, which is why it survivedcss-loaderuntouched.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 (
CAAA→AAAA).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
rspackstill 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 againstmain:prulemain(#21857 + #21861), without this changemain, with this changeThe 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
rspackhandles a string result, this can be dropped — a comment onremoveBOM()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 (removeBOMFromResultis only tapped forjavascript/*andcss/*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 thecharset-utf-8fixture withstyle: "compressed"and asserts the result does not start with a BOM.sourceMap-options.test.js: "should generate source maps for thecompressedstyle without the removed BOM" — pins the shifted mappings alongside the CSS.One thing worth flagging: in the new source-map snapshots,
sass-embeddedrecordsCAEAwheredart-sassrecordsAAEA.sass-embeddedemits 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
npm run test:basepasses.npm run lint(eslint, cspell,tsc --noEmit, prettier) is clean.types/utils.d.tsregenerated vianpm run build:types; a patch changeset is included.🤖 Generated with Claude Code
https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky