Skip to content

Dart Sass quietly reordered my CSS

Posted in Css, Sass

By Dušan Dželebdžić

Photo by Amanda Jones on Unsplash
Photo by Amanda Jones on Unsplash

I spent a day upgrading the frontend toolchain of a client's Symfony portal. Webpack Encore 4 to 6, a stack of loader bumps, a regenerated package-lock.json, all of it driven by npm audit findings. The kind of work where the deliverable is a number going to zero. It did: 35 vulnerabilities down to none. npm run build compiled without a single warning, the dev server booted, the pages looked fine. Merged to staging, closed the laptop.

The next day a screenshot landed in my inbox. The account navigation, a horizontal tab bar on every desktop breakpoint since the site launched, was now a vertical list of links stacked on top of each other. Full width, one per row, like the stylesheet had given up halfway through.

Nobody had touched a stylesheet. I checked. The diff for the whole upgrade contained exactly zero .scss changes.

The suspect that didn't do it

My first thought was PurgeCSS. It only runs in production builds, it's exactly the kind of tool that eats classes when a toolchain shifts under it, and a quick grep seemed to confirm it:

$ grep -c 'account-navigation' public/build/app.4d0c7f87.css
0

Zero matches for the broken component. Case closed, right?

Wrong suspect. account-navigation was just the SCSS filename. The actual selectors inside it were .account-nav-section and .account-nav, and those were all present in the compiled output. Every declaration, every media query, nothing purged. I went through them one by one and they were all there.

They were just in the wrong order.

Same rules, different cascade

Here's the compiled CSS for the navigation list, straight from the broken build:

@media (min-width: 630px) {
.account-nav ul { flex-direction: row; }
}
.account-nav ul { display: flex; flex-direction: column; }

Read that again. The media query comes first. The unwrapped flex-direction: column comes after it, so at every viewport width, on every screen, column wins. The desktop row rule is sitting right there in the file, fully intact and permanently overruled.

The old build shipped the same two rules in the opposite order. Base rule first, media override second, row wins on desktop. Same declarations, opposite layout.

The source looks like this, and has looked like this for years:

ul {
@include media-breakpoint-up-custom(630px) {
flex-direction: row;
}
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
}

Media override first, base declarations after. Sass used to fix this up silently: any plain declarations written after a nested block got hoisted above it in the output, so the base rule always came first and the cascade worked out. The pattern was never correct, exactly. It compiled to the right thing because the compiler was quietly reordering your code for you.

That's the behavior that changed. Sass calls it mixed declarations: CSS itself now allows nesting, and in plain CSS, declarations after a nested rule stay after it. Dart Sass moved to match. Declarations compile in source order, even when that means emitting the outer selector twice.

Why there was no warning

Dart Sass did this by the book. Version 1.77.7 started printing deprecation warnings for every mixed declaration it hoisted. Version 1.92.0 flipped the behavior and retired the warning. Anyone building with a version in between got told, on every single build, exactly which lines would change and how to fix them.

We never saw any of it. The project's lockfile had sass pinned at 1.69.5, from before the deprecation existed. Regenerating the lockfile resolved the ^1.69.5 constraint to 1.102.0, from after the deprecation had run its course. The entire warning window, fourteen releases of increasingly loud notices, fell into the gap between the version we had and the version we got. One npm install jumped from "old behavior, no warning" straight to "new behavior, no warning".

The build was clean because there was nothing left to warn about.

Finding every occurrence

One broken component means there are others. I didn't fancy eyeballing every stylesheet, so I wrote a throwaway scanner: walk each SCSS file, track brace depth, and flag any plain declaration that appears after a nested block has closed inside the same rule.

stack = []
for lineno, line in enumerate(src.split('\n'), 1):
s = line.strip()
if '{' not in s and '}' not in s and ':' in s and s.endswith(';'):
if stack and stack[-1]['nested_closed']:
print(f"{path}:{lineno}: {s}")
for ch in s:
if ch == '{':
stack.append({'nested_closed': False})
elif ch == '}':
if stack: stack.pop()
if stack: stack[-1]['nested_closed'] = True

Crude, line-based, ignores comments unless you strip them first. It found 25 flagged declarations across 7 files in about a second. Three of them were real visible bugs: the stacked navigation from the screenshot, a registration progress line that was supposed to be hidden below 830px and now wasn't, and a print stylesheet gap that the screen value was overriding. The other four files had the same pattern in places where the reordering happened to collide with nothing.

The fix is as boring as it should be: in every flagged rule, move the plain declarations above the nested blocks. No values change. The compiled output goes back to exactly what the old compiler produced, and the source now says what it means instead of relying on the compiler to shuffle it.

ul {
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
@include media-breakpoint-up-custom(630px) {
flex-direction: row;
}
}

Takeaway

A regenerated lockfile is a mass upgrade wearing a one-line diff. Every ^ constraint in the file re-resolves at once, and any package that deprecated and removed a behavior between your old pin and today does it without a word, because the warnings only ever existed in the versions you skipped. When a layout breaks after a toolchain upgrade and the diff shows no stylesheet changes, don't stop at "are my rules in the output". Check what order they're in. The compiler stopped covering for the source, and the source had been wrong the whole time.


Toolchain upgrade gone sideways? Send me the details and I'll take a look.