Coding in SCSS, like in any other programming language, should always have a goal of optimizing for readability over writing speed. Unfortunately some of the syntax available in SCSS can make it harder to read/understand. An example of this is the parent selector (&
).
The parent selector is handy for pseudo-classes (e.g. &:hover
) and using the context in a flexible manner (e.g. :not(&)
), though we can also abuse this to create "union class names".
.parent {
&-extension {
}
}
This usage poses some issues:
- You can't search for the resulting CSS class used by your HTML (
parent-extension
) within the codebase. - If you use this pattern in a larger file, you may need to look through multiple levels of nesting to mentally calculate the resulting CSS class.
This article follows the ongoing process of creating the union-class-name
command of dcwither/scss-codemods, with the goal of eliminating our codebase's approximately 2,000 instances of the union class pattern.
Future Proofing
To limit the spread of the existing pattern, I introduced the selector-no-union-class-name
Stylelint SCSS Rule to the project. Unfortunately this didn't fix the existing 2,000 instances of this pattern throughout our codebase. In order to make a wider fix, I turned to PostCSS.
PostCSS to the Rescue!
The idea I had was to write a PostCSS script to "promote" nested rules that begin with &-
to their parent context after their parent.
Step 1: This Should be Easy, Right?
Using AST Explorer as an experimentation tool, I played with transforms until I found something that looked like it worked:
export default postcss.plugin("remove-nesting-selector", (options = {}) => {
return (root) => {
root.walkRules((rule) => {
if (rule.selector.startsWith("&-")) {
rule.selector = rule.parent.selector + rule.selector.substr(1);
rule.parent.parent.append(rule);
}
});
};
});
Attempt 1 AST Explorer snippet
The first problem I noticed was that the script was reversing the classes it promoted. This can change the precedence in which conflicting CSS rules are applied, resulting in a change in behavior.
.some-class {
&-part1 {
}
&-part2 {
}
}
// becomes
.some-class {
}
.some-class-part2 {
}
.some-class-part1 {
}
This may not be a problem if those classes aren't used by the same elements, but without the relevant HTML, we have no way of knowing whether that's the case.
Step 2: Okay, Let's Fix That One Bug
So all we need to do is maintain the promoted class orders, right?
export default postcss.plugin("remove-nesting-selector", (options = {}) => {
return (root) => {
let lastParent = null;
let insertAfterTarget = null;
root.walkRules((rule) => {
if (rule.selector.startsWith("&-")) {
const ruleParent = rule.parent;
rule.selector = ruleParent.selector + rule.selector.substr(1);
if (lastParent !== ruleParent) {
insertAfterTarget = lastParent = ruleParent;
}
ruleParent.parent.insertAfter(insertAfterTarget, rule);
insertAfterTarget = rule;
}
});
};
});
Attempt 2 AST Explorer snippet
Now promoted classes maintain their order, but the transformed SCSS fails to build because of SCSS variables that don't exist where they're referenced.
.some-class {
$color: #000;
&-part1 {
color: $color;
}
}
// becomes
.some-class {
$color: #000;
}
.some-class-part1 {
color: $color;
}
This is where I started to realize the complexity of this problem. Variables can reference other variables, so we need to deal with that recursion. What about name collisions? What if I break something that was already working in an attempt to fix something else?
Step 3: Time For Some Structure
I wasn't going to finish this project in an afternoon with AST Explorer. At this point I decided to move the project into a GitHub repo so I could manage the increased complexity.
From here, the development process became much more formal:
- Wrote tests for existing code.
- Wrote test stubs for features I wanted to implement.
- Created a GitHub project (Kanban board) to track tasks.
- Started thinking about a CLI that others could use.
- Documented the intended behavior in a README.
Even though I was the only person working on this, it became necessary to follow these practices as the project grew because I could no longer hold the entire project and behavior in my head.
Verifying
Unit tests, while helpful for documenting and verifying assumptions, are insufficient for ensuring the transform won't have any negative impacts on the resulting CSS. By compiling the SCSS before and after the transformation, we can diff
the CSS to confirm there are no changes.
diff --side-by-side --suppress-common-lines \
<(grep -v "/\* line" [before_tranform_css]) \
<(grep -v "/\* line" [after_transform_css])
If you're interested in the more complicated testing I did, you can check out Writing Cleaner Tests with Jest Extensions.
All the Bugs So Far
So what did I realize I had missed along the way?
- Multiple nesting selectors in a given selector.
- Scoped variables that need to be promoted along with the promoted rules.
- In Grouping Selectors (
.a, .b
), every member must begin with&-
for the rule to be promoted. - Not accounting for the multiplicative factor of nested grouping selectors (see this test).
- Duplicate scoped SCSS variables.
- Promoting a rule may change the order of the rules in the compiled CSS.
- Promoting SCSS variables to global scope can affect other files.
- SCSS variables can have interdependencies and may require recursive promotions.
- Everything about variables applies to functions and mixins.
Learnings Re-Learnings
This project isn't finished, but it has finished its arc of escalating from an afternoon of coding in a web editor to having the necessary infrastructure and testing to continue developing with confidence.
The general lesson here, which I find myself relearning from time to time, is that the work necessary to fulfill an idea is often much more complex than what you initially imagine. Because I hadn't spent much time with SCSS in a while, variables, mixins, and grouping selectors weren't top of mind. I had a myopic perspective of the language and problem (nesting and parent selector) that made the problem appear much simpler than in reality.
The bright side is, as I realized the problem needed a more complex solution, I adapted well, gradually increasing the process around the solution. Moving assumptions, requirements, and specifications out of my head and into code/tests/project boards made the entire project more manageable. The other learning is that I no longer assume that this transform is correct - it's only correct enough to be useful in the scenarios I have encountered.
If you're interested in the project, you can check it out below:
dcwither / scss-codemods
SCSS codemods written with postcss plugins
scss-codemods
This project uses postcss to refactor scss code to conform to lint rules that are intended to improve grepability/readability.
Installation
Globally via npm
npm i -g scss-codemods
Running on-demand
npx scss-codemods [command] [options]
union-class-name
"Promotes" CSS classes that have the &-
nesting union selector. Attempts to fix issues flagged by scss/no-union-class-name stylelint rule.
e.g.
.rule {
&-suffix {
color: blue;
}
}
// becomes
.rule-suffix {
color: blue;
}
Intended to improve "grepability" of the selectors that are produced in the browser.
Usage
scss-codemods union-class-name --reorder never <files>
Options
--reorder
Determines the freedom provided to the codemod to reorder rules to better match the desired format (default: never
).
Values:
-
never
: won't promote rules if it would result in the reordering of selectors. -
safe-only
: will promote rules that result in the reordering of selectors as long as the reordered selectors…
Top comments (0)