DEV Community

loading...
Cover image for Everything's More Complex Than it First Appears

Everything's More Complex Than it First Appears

dcwither profile image Devin Witherspoon ・5 min read

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 {
  }
}
Enter fullscreen mode Exit fullscreen mode

This usage poses some issues:

  1. You can't search for the resulting CSS class used by your HTML (parent-extension) within the codebase.
  2. 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);
      }
    });
  };
});
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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;
      }
    });
  };
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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?

  1. Multiple nesting selectors in a given selector.
  2. Scoped variables that need to be promoted along with the promoted rules.
  3. In Grouping Selectors (.a, .b), every member must begin with &- for the rule to be promoted.
  4. Not accounting for the multiplicative factor of nested grouping selectors (see this test).
  5. Duplicate scoped SCSS variables.
  6. Promoting a rule may change the order of the rules in the compiled CSS.
  7. Promoting SCSS variables to global scope can affect other files.
  8. SCSS variables can have interdependencies and may require recursive promotions.
  9. 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:

GitHub logo 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Intended to improve "grepability" of the selectors that are produced in the browser.

Usage

scss-codemods union-class-name --reorder never <files>
Enter fullscreen mode Exit fullscreen mode

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…

Discussion (0)

Forem Open with the Forem app