My scoped CSS was correct, the elements were correct, and none of it applied

4 min read AstroCSSVerificationFrontend

A tool on this site shipped with every severity dot zero pixels wide and every badge a square block. The stylesheet said otherwise, the markup said otherwise, the page returned 200 and the console was clean. Astro scopes a plain <style> with an attribute it stamps on elements that exist at build time, and everything on that page is built at runtime with innerHTML.

TL;DR · THE FIX

A build-time-scoped CSS system draws its boundary when it builds. Astro compiles .sa-dot into .sa-dot[data-astro-cid-vd5fpwql] and stamps that attribute on the elements it can see at build time; nodes you create later in the browser never carry it, so the rules match nothing, with no error and no warning. Fix is <style is:global>. The wider point is that no check reading your files can see this, because your files are right: assert getComputedStyle on a generated node instead.

The symptom

I shipped a browser tool on this site, and everything was green:

build clean, 134 pages
page answers 200
zero errors in the console
four findings rendered, four dots present in the markup

Every one of those was true. You paste a schema dump, it audits it, the findings are correct. It just looked flat, in a way I could not name at a glance, until I noticed the specific thing: the little coloured severity dot next to each finding was not on the screen at all.

What I tried first

The two obvious checks both passed. The rule was in the file, exactly as written:

.sa-dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 999px;
  display: inline-block;
}

And the elements were on the page, with the right class on every one:

<span class="sa-dot"></span>
<span class="sa-dot"></span>
<span class="sa-dot"></span>
<span class="sa-dot"></span>

Correct rule, correct element, and nothing anywhere saying why they did not connect: no console warning, no build warning, no 404 on a stylesheet. So I stopped reading my own files and measured what the browser had computed, which is one line:

getComputedStyle(document.querySelector('.sa-dot')).width
dot width         0px
dot display       inline
dot corners       0px
badge display     block
badge corners     0px

width: auto on an empty inline span is zero pixels wide, which is why there was nothing to see. The badge beside it was a plain block with square corners where the rule asked for a rounded inline-flex pill.

What was happening

I had been reading the rule I wrote, and the build ships a different one. Astro scopes a plain <style> block by stamping a data-astro-cid-* attribute onto the elements that exist in the template at build time, and rewriting every selector in that block to require it. Straight out of the built HTML:

.sa-dot[data-astro-cid-vd5fpwql]{width:.5rem;height:.5rem;border-radius:999px;display:inline-block}

That rule asks for a mark nothing on the page will ever carry, because every dot, pill and finding card is created at runtime with innerHTML after the audit runs. Those nodes were not in the template, so they were never stamped, so the selector matches none of them. From the compiler’s point of view it did what it was asked. From the browser’s point of view a selector matched nothing, which happens on every page on the internet constantly. The failure only exists in the gap between the two.

The fix

One word:

-<style>
+<style is:global>

The build stops adding the condition and the shipped selector goes back to the one I wrote:

.sa-dot{width:.5rem;height:.5rem;border-radius:999px;display:inline-block}

If you would rather not make the whole block global, the other route is to set the properties inline from the JavaScript that builds the markup, which keeps the scoping and pays for it in verbosity.

Leave a comment next to the is:global, because it looks removable to the next person:

<!--
  is:global is load-bearing. Astro scopes a plain <style> with a data attribute it
  stamps on elements that exist at build time. Every pill, dot and card here is
  built at runtime with innerHTML, so it never carries that attribute and the
  scoped rules silently did not apply.
-->

The measurement

Both sides driven in headless Edge against the same static server, so the only variable is the one word, and measured with getComputedStyle on the generated nodes rather than on a sample in the template:

Computed on the runtime nodePlain <style><style is:global>
.sa-dot width0px8px
.sa-dot border-radius0px999px
.sa-pill displayblockflex
.sa-pill border-radius0px999px
.sa-card summary list-styledisclosure-opennone
shipped selector.sa-dot[data-astro-cid-vd5fpwql].sa-dot

And the numbers that were identical on both sides:

Plain <style><style is:global>
HTTP status200200
console errors00
dots in the DOM44
buildclean, 134 pagesclean, 134 pages

What to take from it

No check that reads a file could have caught this, and none of mine did. The markup was right, the stylesheet was right, the page returned 200. A fetch-the-HTML smoke test is green on the broken build and green on the fixed one, because the difference between them exists only in what the browser computed. I found it by eye, by accident, while checking that a different page rendered.

Any build-time-scoped CSS system draws its boundary when it builds. Astro’s data-astro-cid-*, Vue SFC scoped with its data-v-*, and CSS Modules with their hashed class names all rewrite selectors against marks applied at compile time, and anything your code creates after that is on the other side of the line. So if you generate markup in JavaScript, the check worth having is one assertion about the rendered result:

// after the page has built its own DOM
const dot = document.querySelector('.sa-dot');
expect(getComputedStyle(dot).width).not.toBe('0px');

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.