Everything's More Complex Than it First Appears
Tags: css, codequality, javascript, tooling
Published: 2021-01-04 00:58:18

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](https://sass-lang.com/documentation/style-rules/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".

```scss
.parent {
  &-extension {
  }
}
```

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](https://github.com/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](https://github.com/kristerkari/stylelint-scss/blob/master/src/rules/selector-no-union-class-name/README.md) 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](https://astexplorer.net/) as an experimentation tool, I played with transforms until I found something that looked like it worked:

```js
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](https://astexplorer.net/#/gist/d69726c09b4d83e5cbee898ebbe32119/979c549f331c689ae46a4f4f08b7b8ebaac73be9)

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.

```scss
.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?

```js
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](https://astexplorer.net/#/gist/d69726c09b4d83e5cbee898ebbe32119/2331059f49aab0ac7d32f31b6707db36310c9ed6)

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.

```scss
.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](https://github.com/dcwither/scss-codemods/projects/1) (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.

```bash
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](https://dev.to/dcwither/writing-cleaner-tests-with-jest-extensions-5fmb).

## 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](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors#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](https://github.com/dcwither/scss-codemods/blob/v0.1.2/src/plugins/remove-dash-ampersand.test.js#L120-L138)).
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 dcwither/scss-codemods %}