Skip to content

fix(build): stop a stray BOM deleting the loader keyframes from the shipped CSS - #490

Merged
gnbm merged 2 commits into
masterfrom
gm/fix-stylesheet-bom
Aug 8, 2026
Merged

fix(build): stop a stray BOM deleting the loader keyframes from the shipped CSS#490
gnbm merged 2 commits into
masterfrom
gm/fix-stylesheet-bom

Conversation

@gnbm

@gnbm gnbm commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Issue number: resolves #


What is the current behavior?

@keyframes vscomp-animation-spin is silently deleted from the shipped stylesheet, so the options
loader renders as a motionless arc.
"Loading" and "hung" become indistinguishable.

It is latent on master right now and detonates on the next npm run build — the committed
dist/ predates the rule that triggers it, which is the only reason it has not been noticed.

Measured in Chrome by counting keyframes rules in the CSSOM:

Stylesheet vscomp-animation-spin in CSSOM animation-name on the loader
master's committed CSS 1 — present (599 rules scanned) vscomp-animation-spin
freshly built CSS 0 — gone (602 rules scanned) vscomp-animation-spin

The reference survives while its target does not, which is what produces a frozen spinner rather
than a visible error.

Root cause — four steps, none of them wrong on its own

  1. The WCAG 1.4.1 non-colour error cue is written as
    content: '\26A0'.
  2. Dart Sass resolves that escape into a literal (U+26A0) in the output. Confirmed: a fresh
    build contains U+26A0; master's committed CSS contains no non-ASCII byte at all.
  3. Because the output now contains a non-ASCII character, Dart Sass prepends an encoding hint
    @charset "UTF-8"; in expanded output, and a U+FEFF BOM in the compressed output we ship.
    Harmless at offset 0, where every parser strips it.
  4. webpack.BannerPlugin prepends the licence banner in front of it, so the BOM lands at
    offset 167, mid-file — immediately before the first rule. U+FEFF is ≥ U+0080 and therefore a
    valid CSS ident code point, so the parser reads <FEFF>@keyframes vscomp-animation-spin {…} as
    a qualified rule with an unparseable selector and discards the pair.

Verified directly against sass rather than inferred from the docs:

output style default charset: false
expanded @charset "UTF-8"; clean
compressed (what ships) U+FEFF BOM clean

Only the first rule after the banner dies. That it is currently the spinner is luck of partial
ordering — reorder the partials and a different rule vanishes instead, which is why this is fixed at
the source rather than by moving anything around. No source file contains a BOM; all of src/ was
scanned.

What is the new behavior?

Two changes, and both are needed:

  • sassOptions: { charset: false } on sass-loader — the encoding hint is not emitted at all,
    so no BOM can ever reach the banner. This is the systemic half: it protects against any future
    non-ASCII character, not only today's .
  • The cue is emitted as the CSS escape \26A0 rather than the resolved character
    (string.unquote('"\26A0"')), so the stylesheet stays ASCII. This matters because with no
    encoding hint emitted, a non-ASCII byte would decode according to the consuming page's charset —
    keeping the sheet ASCII removes that dependency instead of trading one risk for another.

Verified on the built output: BOM count 0, non-ASCII bytes 0, @keyframes vscomp-animation-spin back in the CSSOM, and the cue still resolves to in the browser from
.vscomp-error-message::before{content:"\26A0"}.

Does this introduce a breaking change?

  • Yes
  • No

No source behaviour, API, class name or visual output changes — other than the spinner spinning
again. The CSS is otherwise equivalent; the only byte-level difference is the removed BOM and the
cue being an escape rather than a literal character, which the CSS parser resolves identically.

Other information

Tests — a class of coverage this repo did not have

Every existing spec exercises behaviour, so none of them can see a rule the CSS parser threw away.
That is precisely how this got through. Two complementary guards:

scripts/build-checks/__tests__/stylesheet-bytes.test.mjs — run by the new npm run test:build
in the e2e job immediately after Build, reading the built file from disk. Asserts no BOM,
ASCII-only, that only ASCII whitespace separates the banner from the first rule, and that every
declared @keyframes is referenced. 3 of 4 red against a bundle built from master's config,
e.g.:

AssertionError: EF BB BF (U+FEFF) found at byte offset 167. A BOM anywhere but offset 0
destroys the rule that follows it; BannerPlugin guarantees offset 0 is not where Sass put it.
Fix with `charset: false` in the sass-loader options, not by reordering rules.

AssertionError: invisible code point(s) between the banner and the first rule: U+FEFF at +1.
These merge into the following selector and the parser drops that rule entirely.

A missing or stale dist/ is a hard failure, not a skip.

Why test:build and not test:scripts: the static job that runs test:scripts never builds,
and the committed dist/ is deliberately stale — it contains neither vscomp-error-message nor
26A0. Placed there, these assertions passed against a bundle predating the rule they exist to
protect, and a PR deleting charset: false without rebuilding would have stayed green. That was
verified against the actual workflow and the committed artefact, not assumed.

cypress/e2e/build-stylesheet-integrity.cy.ts — the half only a browser can answer: that the
CSSOM's first rule is the file's first rule, that every animation the file references resolves to
a surviving @keyframes, that the loader's animation is actually applied, and that the cue reaches
the page. 3 of 4 red against the same unfixed bundle.

Nothing is hard-coded to vscomp-animation-spin: the defect class is "whichever rule follows the
banner", so naming today's first rule would stop guarding anything once the partials are reordered.
Both sides are derived from the file.

Verification

  • E2E 414/414 across 27 specs
  • npm run test:scripts 75/75 (3 red before the fix)
  • npm run validate (tsc + ESLint + Stylelint) clean

No build output is committed — dist/, dist-archive/ and docs/assets/ stay pinned to master,
per the PR rule in .github/README.md. Note that the fix therefore has no visible effect until a
release build is run.


Correction to an earlier revision of this description

An earlier version of this PR stated that the Cypress byte checks passed against a bad build
"because the HTTP layer strips the BOM in transit, even with encoding: 'binary'". That was
wrong
, and the real cause was found while making the assertions generic:
cy.request('assets/virtual-select.min.css') resolves against a baseUrl ending in #/, so the
request went to the document root and the server returned index.html — 2,478 bytes of ASCII,
BOM-free. The assertions were inspecting the docs homepage and never saw the stylesheet at all.
curl against the same path returns the real CSS with its BOM, so neither the server nor the HTTP
layer was ever the problem.

The browser-side cases now fetch sheet.href — the URL the page actually loaded — via win.fetch(),
which cannot drift from what is under test.

One related bug of my own is also fixed here: the first version of the generic "nothing precedes the
first rule" guard used trimStart() to isolate the gap, and JS counts U+FEFF as whitespace, so
it deleted the exact character it was looking for and passed on a BOM-carrying build. The gap is now
inspected untrimmed, and both files carry a comment saying why.

…hipped CSS

Dart Sass prepends an encoding hint whenever its output contains a
non-ASCII character - a U+FEFF BOM in the compressed output we ship -
and BannerPlugin then prepends the licence banner in front of it. A BOM
at offset 0 is stripped by every CSS parser; at offset 167 it is a valid
CSS ident code point, so the parser reads it as the start of a selector,
swallows the rule that follows and drops both.

The casualty was @Keyframes vscomp-animation-spin, the first rule after
the banner, while .vscomp-options-loader::before kept referencing it -
so the options loader rendered as a motionless arc and 'loading' became
indistinguishable from 'hung'. Measured in Chrome: the keyframes rule is
present in master's committed CSS and absent from a fresh build. Any
first rule is vulnerable; the spinner was only the one there.

The trigger is the WCAG 1.4.1 error cue added for the 1.4.0 work
(content: '\26A0'), which Sass resolves to a literal warning sign. It is
latent on master today because the committed CSS predates that rule, and
would have shipped on the next release build.

Two changes, both needed:

- sassOptions: { charset: false } stops the hint being emitted at all,
  so no future non-ASCII character can resurrect the BOM;
- the cue is emitted as the CSS escape \26A0 rather than the resolved
  character, keeping the stylesheet ASCII so the glyph does not depend
  on the consuming page's charset either.

Guarded by byte-level assertions over the built file
(scripts/ci/__tests__/stylesheet-bytes.test.mjs: no BOM, ASCII-only,
keyframes present) plus a browser check that the rule resolves in the
CSSOM. The byte checks live in the Node suite deliberately: written as
cy.request() they passed against a known-bad build, because the HTTP
layer strips the BOM in transit even with encoding: 'binary'.

Suite: 414/414 across 27 specs; scripts 75/75 (3 red before the fix);
tsc/eslint/stylelint clean.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR Test Results — ✅ all checks passed

Check Result Time
Typecheck 2s
ESLint 2s
Stylelint 1s
CI Scripts 1s
Build 3s
a11y-aria-label.cy.ts ✅ 10/10 5s
a11y-close-clears-highlight.cy.ts ✅ 7/7 6s
a11y-escape-close.cy.ts ✅ 6/6 2s
a11y-listbox-multiselectable.cy.ts ✅ 3/3 1s
a11y-live-region.cy.ts ✅ 18/18 6s
a11y-reduced-motion.cy.ts ✅ 3/3 1s
a11y-required-error.cy.ts ✅ 20/20 4s
a11y-search-arrow-navigation.cy.ts ✅ 13/13 7s
a11y-select-all.cy.ts ✅ 7/7 3s
a11y-server-search-announcements.cy.ts ✅ 6/6 4s
a11y-target-size.cy.ts ✅ 5/5 2s
build-stylesheet-integrity.cy.ts ✅ 4/4 1s
examples.cy.ts ✅ 219/219 1m44s
observer-listener-lifecycle.cy.ts ✅ 7/7 1s
perf-resize-throttle.cy.ts ✅ 3/3 1s
perf-scroll-aria.cy.ts ✅ 6/6 4s
perf-text-measurer.cy.ts ✅ 4/4 3s
secure-text-warning.cy.ts ✅ 4/4 1s
security-ampersand-storage.cy.ts ✅ 16/16 3s
security-chrome-label-props.cy.ts ✅ 10/10 2s
security-classnames-xss.cy.ts ✅ 2/2 1s
security-customdata-xss.cy.ts ✅ 2/2 1s
security-global-defaults.cy.ts ✅ 11/11 1s
security-hidden-input-name.cy.ts ✅ 7/7 1s
security-proto-value.cy.ts ✅ 9/9 1s
security-quote-escaping.cy.ts ✅ 12/12 2s
timer-cleanup.cy.ts ✅ 2/2 1s

Tested commit: c05ecd7 · Run #18

@gnbm gnbm added the bug Something isn't working label Aug 8, 2026
@gnbm
gnbm requested a lite review from Copilot August 8, 2026 11:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents release-build CSS corruption where a mid-file UTF‑8 BOM (introduced by Sass and shifted by BannerPlugin) causes the first CSS rule—previously @keyframes vscomp-animation-spin—to be silently discarded, freezing the loader animation.

Changes:

  • Disable Sass charset emission in the webpack sass-loader pipeline (sassOptions.charset: false) to prevent BOM insertion.
  • Emit the warning-sign cue as a CSS escape sequence rather than a literal non-ASCII glyph to keep the generated stylesheet ASCII-only.
  • Add byte-level Node tests plus a Cypress E2E integrity check to catch future build regressions where the shipped stylesheet fails to parse as intended.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
webpack.config.js Sets sassOptions.charset: false to stop Sass from emitting encoding hints/BOMs that can break CSS parsing after the banner is prepended.
src/sass/partials/virtual-select.scss Changes the error cue to output a CSS escape to keep output ASCII and avoid reintroducing encoding-related artifacts.
scripts/ci/tests/stylesheet-bytes.test.mjs Adds byte-level assertions ensuring the built CSS has no BOM, stays ASCII-only, and contains the required keyframes/reference.
cypress/e2e/build-stylesheet-integrity.cy.ts Adds browser-level checks that keyframes survive CSS parsing and that the non-colour cue still renders.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/ci/__tests__/stylesheet-bytes.test.mjs Outdated
…d stop naming one rule

Three findings from a review of 237fd03. The fix itself held up; the
guard around it did not.

1. The byte guards never saw a fresh build. They read dist/, but
   npm run test:scripts runs in the 'static' CI job which does not
   build, and the committed dist/ is deliberately stale - it contains
   neither vscomp-error-message nor 26A0. So they passed against a
   bundle predating the rule they exist to protect, and a PR deleting
   charset: false without rebuilding would have stayed green.

   Moved to scripts/build-checks/__tests__/ (out of the scripts/ci/**
   glob) behind a new npm run test:build, wired into the 'e2e' job
   immediately after Build. A missing or stale dist/ is now a hard
   failure instead of a skip - a skip is how this stayed invisible.

2. The assertions named vscomp-animation-spin, but the defect class is
   'whichever rule follows the banner'. Nothing is hard-coded now: Node
   asserts only ASCII whitespace separates the banner from the first
   rule and that every declared @Keyframes is referenced; Cypress
   asserts the CSSOM's first rule is the file's first rule, and that
   every referenced animation resolves to a surviving @Keyframes.
   Reordering the partials can no longer silence the guard.

3. dist-archive/ was rewritten with post-1.3.0 code by the rebuild.
   Never committed; restored. Recurs on every local build until the
   version is bumped, which 'Before tagging' already covers.

Corrects a false claim in 237fd03's comments: the Cypress byte checks
did not pass 'because the HTTP layer strips the BOM'. cy.request() with a
relative path resolved against a baseUrl ending in '#/', so the request
hit the document root and the server returned index.html - 2478 bytes of
ASCII. They were inspecting the docs homepage, never the stylesheet;
curl against the same path returns the real CSS with its BOM. The
browser-side cases now fetch sheet.href via win.fetch(), which cannot
drift from what is under test.

Also fixes a vacuous assertion introduced while writing finding 2's
guard: JS counts U+FEFF as whitespace, so trim()/trimStart() deleted the
character being looked for and the check passed on a BOM-carrying build.
The gap before the first rule is now inspected untrimmed.

Verified: 3 of 4 Node checks and 3 of 4 Cypress cases red against a
bundle built from master's config, all green with the fix. Suite 416/416
across 27 specs; validate clean.
@gnbm
gnbm merged commit fe79b7a into master Aug 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants