TL;DR
- My stylesheet started with two UTF-8 byte order marks instead of one. The decoding algorithm browsers use strips only the first, so the second became part of the first selector, and the
:rootrule holding 130 colour tokens stopped matching on 462 pages. - All checks and the deploy passed. Only a live measurement of the rendered page caught it: background
#ffffffwhere it should have been#f5f0e8. - Check for a BOM after byte 0 in CI, and assert computed styles on the live page, not just files.
The background that came back white
I built MyIPScan, a set of free network-privacy checks served as a static site. The first rule in its stylesheet is a :root block with 130 colour tokens as CSS custom properties.
After a deploy, a live measurement of the page background came back #ffffff. It should have been #f5f0e8, a warm off-white.
Nothing else flagged it. Every check passed, the deploy went through, and the 130 tokens were undefined on all 462 pages that load that stylesheet.
Why nothing went red
If your design system lives in custom properties, your whole visual identity hangs off one selector. When that selector stops matching, nothing throws. No parse error, no failed step, no console warning. The pages still load, and they just look wrong.
Two suspects that don't fit
A stale cached stylesheet is the obvious first suspect. It explains a page that looks old, not one that has lost every token at once.
A cascade problem, some later rule overriding the tokens, is the second. One line in the console separates the two:
getComputedStyle(document.documentElement).getPropertyValue("--bg");
An overridden property still has a value. An empty string means the property was never declared for that element, so the cascade is not the problem. If the declarations are in the file and still don't apply, look at the selector.
The cause, in six bytes
$ head -c 6 styles.css | od -An -tx1
ef bb bf ef bb bf
EF BB BF is the UTF-8 encoding of U+FEFF, the byte order mark. This file has two.
One is harmless. CSS Syntax Level 3 decodes a stylesheet with the Encoding Standard's decode algorithm, which sniffs for a BOM, reads three bytes and discards them. It does that once. There is no loop.
The second EF BB BF goes through the UTF-8 decoder as an ordinary character, U+FEFF, and reaches the tokenizer. CSS Syntax lists U+FDF0 to U+FFFD among its non-ASCII ident code points, so U+FEFF can start an identifier. The first rule stops being this:
:root { --bg: #f5f0e8; /* ...129 more */ }
and becomes this:
\FEFF:root { --bg: #f5f0e8; /* ...129 more */ }
That is a type selector for an element whose name is U+FEFF, combined with :root. In an HTML page the root is <html>, so the rule matches nothing. It still parses and still shows up in the CSSOM.
You might expect the browser to drop the rule as invalid. In a minimal repro I built for this article (headless Chromium, stylesheet served over local HTTP), it was still there, with the extra character in front:
JSON.stringify(document.styleSheets[0].cssRules[0].selectorText);
// ":root" with an invisible U+FEFF in front of it
From there, var() does what MDN describes for an undeclared property: use the fallback if there is one, otherwise resolve to an invalid value, and the property is treated as unset. In the repro, background: var(--bg, #fff) gave rgb(255, 255, 255), and background: var(--bg) gave rgba(0, 0, 0, 0), transparent over a white canvas. Either way, it looks white. With a single BOM, the same file gave rgb(245, 240, 232).
How a file gets two
The usual ways a file ends up with a second BOM:
- concatenating files that each start with a BOM, when the first piece is empty apart from its BOM;
- an editor or tool prepending a BOM to a file that already has one;
- a build step that writes its own BOM in front of content that already carries one.
Don't count on your linter. In the repro, stylelint 17.16.0 with selector-type-no-unknown exited 0 on the double-BOM file, while the same rule rejected foo:root as an unknown type selector.
Your search tool can hide it too. ripgrep sniffs for a BOM by default and strips a leading one before searching (checked with ripgrep 14.1.0), so a search for the bytes misses a single BOM at the start of a file. rg -E none searches raw bytes. GNU grep works if you force the C locale; under a UTF-8 locale the same pattern found nothing in my test with grep 3.11:
LC_ALL=C grep -rlP '\xEF\xBB\xBF\xEF\xBB\xBF' --include='*.css' dist/
The fix and how to confirm it
The fix is deleting the second three bytes. The confirmation is the same live measurement, read in the format the browser gives you. getComputedStyle(document.body).backgroundColor returns rgb(), not hex, so the expected value is rgb(245, 240, 232): 0xf5 = 245, 0xf0 = 240, 0xe8 = 232. The broken value, #ffffff, is rgb(255, 255, 255). Compare a hex string with an rgb() string and your check fails for the wrong reason.
Two checks are worth adding. These are illustrations, not production code.
A byte check for CI, which allows a BOM at offset 0 and fails on any after it:
// check-bom.mjs
// run: find dist -name '*.css' -exec node check-bom.mjs {} +
import { readFileSync } from "node:fs";
const BOM = Buffer.from([0xef, 0xbb, 0xbf]);
let bad = 0;
for (const file of process.argv.slice(2)) {
const buf = readFileSync(file);
let at = buf.indexOf(BOM, 1); // offset 0 is allowed
while (at !== -1) {
console.error(`${file}: stray BOM at byte ${at}`);
bad++;
at = buf.indexOf(BOM, at + 1);
}
}
process.exit(bad ? 1 : 0);
Use find rather than dist/**/*.css: without globstar, bash expands ** like * and skips files directly in dist/.
And a rendered check, which catches this bug and every other way a token can vanish:
// after deploy, in a headless browser (Playwright shown)
await page.goto(LIVE_URL);
const bg = await page.evaluate(() =>
getComputedStyle(document.body).backgroundColor
);
if (bg !== "rgb(245, 240, 232)") throw new Error(`background is ${bg}`);
The byte check knows one bug. The rendered check doesn't care why the colour is wrong, which is the point. Pick one token you'd notice, assert its exact computed value, and run it against the live URL.
The rule I'm keeping
A green pipeline checks the files you shipped. Only a rendered page tells you what the browser did with them. A linter reads your CSS as text. The browser reads it as bytes, through an algorithm that removes exactly one BOM and treats a second as part of your selector.
Has a stylesheet ever passed every check you had and still rendered wrong for you? I'd like to know what finally caught it.
Top comments (0)