Better Together - Co-locating Ember.js Component Files

Like peanut butter and potato chips in a sandwich (don’t @ me jelly fans) or the members of a tragically-disbanded boy band, some things are meant to be together. This is true of the component template, class, and (if you have them) CSS Module style files in an Ember app, but until recently they were relegated to their own file paths. We recently migrated over to this co-located format here at Iora Health; this post describes some of the issues that arose in the process and the ultimate benefits.

Jackson 5 ABC
Spoiler: It was not quite as easy as this.

Why co-locate?

You can read more about the historical context and rationale on the detailed Ember RFC Pull Request. To summarize and expand upon the points made there, co-locating component files allows for:

  • Clear emphasis on the fundamental coupling between templates and classes
  • A single enumeration of components within an app’s file tree - especially helpful when reviewing Pull Requests (PRs), as component files now appear next to each other in the file list!
  • Consistency with route template conventions by removing app/templates/components
  • A single import for components

A few members of the Engineering team had been discussing these benefits, so I spearheaded the initiative in my Engineer’s Time (the 30% allocation of our time at work towards self-directed projects). The goal: to migrate all of our component template and style files into app/components for our facesheet Ember app* with no interruption to developer or user experience.

*‘Face sheet’ is a common term for a patient’s clinical record in the jargon-heavy world of medical technology

A first pass

An initial attempt to co-locate all of our component files in one go didn’t go as smoothly as I had hoped for.

At the time we had about 380 files in app/templates/components and about 250 in app/styles/components:

find app/components -type f | wc -l
# 509
find app/templates/components -type f | wc -l
# 381
find app/styles/components -type f | wc -l
# 247

Running the Ember component template colocation migrator code mod was straightforward, as was using some command line / VSCode magic to migrate the corresponding styles files. I also updated all of the old component references (style imports, template references, etc) I could find based on regex patterns.

I encountered a few issues with this approach, however, some technical and some process-related:

Imports of and references to CSS Module styles files without a file path suffix

One gotcha for Ember apps that make use of CSS Modules by way of ember-css-modules is that we occasionally reference component style files. There are a few common use cases for this, including*:

*All of the following code examples have been simplified for clarity

// facesheet/app/components/facesheet/nav/communications-button.hbs

<TrackedLinkTo
  @activeClass={{local-class 'nav-link-active' from='facesheet/components/facesheet/sidebar/navigation-menu}}
  class='eng-communications-tab {{local-class 'nav-link' from='facesheet/components/facesheet/sidebar/navigation-menu}}'
  Communications
</TrackedLinkTo>
Passing a local class as an argument to a child component
// facesheet/tests/integration/components/facesheet/sidebar/accordions/lab-item-test.js

import styles from "facesheet/components/facesheet/sidebar/accordions/lab-item";

it("highlights abnormal lab results in the popover", async function () {
  this.set("lab.labResults.firstObject.isAbnormal", true);
  const abnormalResultClass = `.${styles.abnormal}`;

  await triggerEvent(".eng-accordion-lab-81", "mouseenter");

  expect(findAll(abnormalResultClass)).to.have.length(1);
});
Importing a local style to derive dynamic styles on the fly and/or to obtain a test selector with that style
// facesheet/app/components/facesheet/patient-banner.js
import styles from "facesheet/components/facesheet/patient-banner";

@classic
export default class PatientBanner extends Component {
  init() {
    super.init(...arguments);
    this.set("styles", styles);
  }

  @computed("styles")
  get patientLinkStyles() {
    const patientStyle = this.get("styles.link");
    return `${patientStyle} eng-edit-patient`;
  }
}
Importing styles to combine CSS Module and global styles

With co-location, there are now up to three files with the same relative path for the component - a template (.hbs), component class (.js), and styles (.css) file. Using the old suffix-free import or reference statements in template or test files (as seen above) caused issues with file resolution, wherein the resolver attempted to load the component class file as opposed to the style file. This resulted in tricky bugs, especially in the case of path references in component templates where the import will not raise an error but will cause regressions in the styles for that component.

Once I identified this issue it was easy enough to identify a fix - adding the .css file suffix to any such references - but tracking it down in the first place and then ensuring that we captured all references was slightly trickier.

Harry Styles juggling
Kinda like this

Components using the localClassNames/localClassNameBindings properties

Another issue that emerged was the incompatibility of the localClassNames component property. In the world of classic components, this property served to apply local classes to the HTML class attribute of a component’s tag. Our Ember 3.24 app still includes many classic components since we are still in the process of migrating to Glimmer components, so we had a number of these property references lying around.

@classic
@classNameBindings("className")
export default class MarkerRow extends Component {
  localClassNames = ["row"];
}
<div id="ember131" class="ember-view _row_twwq0z">...</div>
Setting localClassNames to row applies the row local style (compiled to the class name ‘_row_twwq0z’) directly to the component <div>

Co-located components with these properties do not apply the component-level style specified in the property, causing style regressions, so I had to find a way to refactor away the components that made use of this property. While I was at it I extracted classNames/classNameBindings properties as well, as it wasn’t immediately clear if those were causing issues as well, and it would eventually be necessary anyways as part of a parallel initiative to convert our components to Glimmer.

Screenshot of Chirp with correct styles
Legacy component with the localClassNames property applied
Screenshot of Chirp with incorrect styles
Co-located component still using (or trying to use) localClassNames - yikes!

Merge conflicts for other developers who weren’t yet co-locating-as-you-go

The third and final issue was a process issue - some engineers were co-locating new components or components they had edited in a PR, but some were not, and the push to co-locate in one fell swoop caused friction for those who hadn’t yet boarded the co-location train. We needed to ensure that this work would not cause headaches or merge conflicts in order to safely proceed.

Backstreet Boys I Want It That Way
Avoiding unnecessary merge conflicts

Second (or tenth) time’s a charm

My “second pass” at this was really more like 10 individual PRs and a process tweak over the course of the next few months.

First I set aside my attempt to co-locate in one shot and developed a game plan for a more iterative, low-friction approach. I documented a proposal for this approach in our Chirp documentation repository and solicited feedback from the entire team on it, as well as communicating the expectation that all components touched in a Pull Request should be co-located going forward. I considered building a linting mechanism for that but thought the work would be quick enough that it wouldn’t be necessary (more on that later!).

I then set about extracting the localClassNames-style properties to lay the groundwork for further co-location. My initial hope was to migrate all of the affected classic components to Glimmer components, an initiative I had been contributing to anyways. This worked for many of the components, but unfortunately some made use of patterns that made it difficult to easily convert them to Glimmer. My colleague Alex Rothenberg suggested that I simplify by making the more complex components tagless using the @tagName property, rather than slow my momentum by trying to wrangle them all into Glimmerland. This pivot sped the process up quite a bit, though it still took a few Pull Requests to land in Engineer’s Time.

import classic from 'ember-classic-decorator';
import {
  classNames,
  attributeBindings,
  classNameBindings,
} from '@ember-decorators/component';
import Component from '@ember/component';

@classic
@classNames('eng-immunizations-log-row')
@classNameBindings('immunization.error:immunizations-log-row--error')
@attributeBindings('measuredAtForDataTag:data-measured_at')
export default class ImmunizationsLogRow extends Component {
  ...
}
<div local-class="header">...</div>
Old and tagful
import classic from "ember-classic-decorator";
import { tagName } from "@ember-decorators/component";
import Component from "@ember/component";

@classic
@tagName("")
export default class ImmunizationsLogRow extends Component {
  @computed("immunization.measuredAt")
  get measuredAtForDataTag() {
    return parsedMeasuredAt(this).format("YYYY-MM-DDTHH:mm:ssZ");
  }
}
<div class="eng-immunizations-log-row"
     local-class="row {{if this.immunization.error "error"}}"
     data-measured_at={{this.measuredAtForDataTag}}>
    <div local-class="header">
        ...
    </div>
</div>
New and tagless

Once I had extracted classNames and friends from our Ember application and was ready to move on to the last stretch, co-locating the files. I opted to move smaller batches of subfolders to start, trying to target related or adjacent feature areas to speed acceptance testing. I started with just a few simple components to ensure that I had the process nailed down before building up to larger sets of files. I also carefully regression tested each affected component in both development and staging in addition to having a Product Owner approve it.

GitHub pull requests
Spring cleaning 🧹: batching up file co-location

It took 6 PRs to co-locate the entirety of the app’s component files in this fashion, but at the end of the day I was able to release the changes while thoroughly testing the affected areas of the application and without disrupting any other engineers’ work in progress. I also had help from my fellow Chirp team members, who rallied around the initiative (and prevented headaches for themselves!) by co-locating any components they were adding or editing in their own work in the repository.

Inspecting + adapting (two things that are also 👍 together)

I had a few key learnings from going through this process.

The first learning is that there is significant value in visual inspection tools to automatically flag style regressions. We have broad test coverage of the functionality of our app, but lighter coverage of visual changes. We have considered such tools (like Percy or open source alternatives) in the past but have not yet prioritized such an integration. Having more confidence to make sweeping style-related changes would allow initiatives like this to proceed more smoothly and with less time intensive regression testing.

The second learning is that process tools are great for some things (in this case obtaining alignment, buy-in, and team awareness), but tech tools are better for others (ensuring consistency in the co-location of others’ new work). Having to rely on regular announcements and PR reviews to shift those who weren’t co-locating previously to the new system was a time consuming and error-prone process. On rare occasions a new non-co-located file was introduced that I had to co-locate in the next batch around. For future work I would consider building a custom lint rule to flag such deviations at build time, before they made it into the main branch.

The last thing worth mentioning is a lesson I am continually learning even many years into a career in Product and Engineering, which is to continuously pare down scope as the temptation for scope creep arises. It was tempting to indulge in my completionist desire to migrate many of the affected components to Glimmer while I was modifying them, but it was essential to focus only on what was needed to get the co-location work finished.

So happy together

The Turtles Happy Together
This counts as a boy band, right?

It took a few months to complete, since the work happened exclusively in my Engineer’s Time and I regression tested all of the components myself, but as of mid-March our Ember app component files are fully co-located! It may feel like a minor change to developers who have been accustomed to the idea for a while now, but has hopefully reduced the cognitive load of onboarding Chirp engineers unfamiliar to Ember, simplified how we think about and search through our component files, and keeps the structure of our app closer to the latest documentation and code from the Ember community.

The app directory
A glimpse into the app/components directory, with all three file types in peaceful cohabitation

That’s it! I hope these learnings may be of use to others who are considering migrating their own Ember app’s component files.

The Beatles waving goodbye