Rewriting Apps in Ember Octane

ijlee2

Isaac Lee

Posted on December 24, 2019

Rewriting Apps in Ember Octane

Originally published on crunchingnumbers.live

Last Friday, Ember 3.15 was dubbed the Octane edition. To see how easy (and fun) writing an Octane app is, I spent the weekend rewriting my apps Ember Animated (v3.8) and Lights Out (v2.18). Let me share what I learned.

If you have tutorials and demo apps, I encourage you to rewrite them in Octane. You can publish both versions to help everyone understand how the programming model in Ember has evolved over time.


1. Ember Animated (3.8 → 3.15)

Between the two, the Ember Animated represents a production app. It features a few complex routes, several components, mocked APIs, and a comprehensive test suite. The problems and joys that I encountered while rewriting, you will likely also.

I didn't use ember-cli-update and codemods because I had somewhat atypical code from trying out Ember Data Storefront and Ember Animated. I figured, by writing a new app from scratch, I would learn Octane faster.

a. Baby Steps

I found an incremental approach to be helpful. Introduce routes one at a time and see what components need to be migrated over. Once a component is in Octane, write or port over rendering tests. After all components are done, write application tests for the route. Move to the next route and the next set of components. Rinse and repeat.

Often, you will find yourself recasting {{action}} modifiers as a mix of @action decorator, {{on}} modifier, and {{fn}} helper.

File: /app/templates/authors/details.hbs

<!-- Before -->
<button type="button" {{action "deleteAuthor" model}}>
    Delete
</button>

<!-- After -->
<button type="button" {{on "click" (fn this.deleteAuthor @model)}}>
    Delete
</button>
Enter fullscreen mode Exit fullscreen mode

Though verbose, the new syntax helps you be clear with your intent. With practice, the syntax will become second nature. Visit the Ember Guides to learn more.

On a related note, take caution when converting actions called on a form submission. (I omitted Ember Concurrency in the following example to make the point clear.)

File: /app/templates/search.hbs

<!-- Before -->
<form {{action "searchStudents" on="submit"}}>
    ...
</form>

<!-- After -->
<form {{on "submit" this.searchStudents}}>
    ...
</form>
Enter fullscreen mode Exit fullscreen mode

The {{action}} modifier calls event.preventDefault(); and prevents page reload for you. In Octane, you express the intent to prevent the default behavior. You can find the event object as the last argument to your function.

File: /app/controllers/search.js

// Before
actions: {
    searchStudents() {
        const skillIds = this.selectedSkills.mapBy('id').join(',');

        ...
    }
}

// After
@action searchStudents(event) {
    event.preventDefault();

    const skillIds = this.selectedSkills.mapBy('id').join(',');

    ...
}
Enter fullscreen mode Exit fullscreen mode

b. Test Suites

You can be confident with a rewrite if you have existing tests. Because my 3.8 tests already followed the new testing paradigm, my 3.15 tests needed a minor update: Replace server​ with this.server for Ember CLI Mirage. Note that, in component, helper, and modifier tests, hbs is now a named import.

File: /tests/integration/components/loading/component-test.js

// Before
import hbs from 'htmlbars-inline-precompile';

// After
import { hbs } from 'ember-cli-htmlbars';
Enter fullscreen mode Exit fullscreen mode

If you don't have existing tests, I encourage you to take time to write them for your future self. Learn more about testing in Ember.

c. Where Do Foos Come from?

As soon as you rewrite routes and components, you will love how you explicitly call things in a template. No more confusion over if {{foo}} is a component, a helper, a passed argument, or a local property. (You now write <Foo>{{foo}}, @foo​, and this.foo, respectively. Ember will throw a helpful error for forgotten mistakes.)

File: /app/templates/students.hbs

<div>
    {{#if this.showHeader}}
        <h1>{{t "layout.students.header.students"}}</h1>
    {{/if}}

    <StudentsGrid
        @students={{@model}}
    />
</div>
Enter fullscreen mode Exit fullscreen mode

Although you are seeing this code for the first time, you can tell that <StudentsGrid> is a component, {{t}} is a (translation) helper, @model is a passed argument, and this.showHeader is a local property. You know which file to look next to learn more.

d. Template-Only Components

Glimmer components don't create a "wrapping-div," so you can say goodbye to tagName, attributeBindings, classNames, and classNameBindings. More often than not, these hindered me from reasoning the HTML code fast. After you remove these properties, smile when you see how many components don't need a backing class. 6 out of my 10 components became template-only.

One caveat with Glimmer components: elementId, which was useful for binding a label to an input for accessibility, no longer exists. Instead, use guidFor(this) in the backing class to create the ID.

File: /app/components/skill-pill/component.js

import { guidFor } from '@ember/object/internals';
import Component from '@glimmer/component';

export default class SkillPillComponent extends Component {
    inputId = `input-${guidFor(this)}`;
}
Enter fullscreen mode Exit fullscreen mode

e. Modifiers

When I dove into the rewrite, I wasn't sure about converting the modifier that I had used to demonstrate Web Animations API. I had used the ember-oo-modifiers addon, but I now wanted to use the official ember-modifier.

To my pleasant surprise, the code remained virtually the same. The new modifier even seemed to have fixed the animation bug that I had seen before. You got to give cheers to Ember contributors for following a well-defined API.

File: /app/modifiers/fade-up.js

import Modifier from 'ember-modifier';

export default class FadeUpModifier extends Modifier {
    didReceiveArguments() {
        const { duration, delay } = this.args.named;

        this.element.animate(
            [
                { opacity: 0, transform: 'translateY(60px)' },
                { opacity: 1, transform: 'translateY(0px)' }
            ],
            {
                duration: duration || 2000,
                delay: delay || 0,
                easing: 'cubic-bezier(0.075, 0.82, 0.165, 1)',
                fill: 'backwards'
            }
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

f. Avoid Shortcuts

In 3.8, I had created the search results-route as a child of the search. After all, a user would first search, then see the results. Nesting had seemed to convey that user flow accurately.

File: /app/router.js

Router.map(function() {
    this.route('search', function() {
        this.route('results');
    });
});
Enter fullscreen mode Exit fullscreen mode

Nesting normally implies that, when the user is on the results page, they will also see the search page. In reality, the app only shows the results page (which is what I had wanted) because I had used renderTemplate to bypass the parent.

I don't recommend this practice since renderTemplate is on the path to deprecation. The alternate solution is just as easy and doesn't load records that are never used in search results:

File: /app/router.js

Router.map(function() {
    this.route('search');
    this.route('search-results', { path: '/search/results' });
});
Enter fullscreen mode Exit fullscreen mode

While you rewrite, I encourage you to return to the happy path that is paved with good practices. Your future updates will be easier. You can also review deprecations regularly and exchange ideas for solutions with people on Discord.

2. Lights Out (2.18 → 3.15)

Between the two, I found rewriting this app to be more interesting and rewarding. It is a simple app in terms of components: There is only 1 component. However, because I had written it while I was still new to Ember and D3, the app was riddled with hard-to-reason control flow. Oh, mixins, CPs, and observers...

By rewriting the app from scratch, I got to understand how to design a D3 component, perhaps with composability in mind.

a. Tracked Properties + Getters 💞

Tracked properties are magic. You no longer worry about whether a component should update along with when, where, why, and how. It just works.™ The code is cleaner, too, because you don't specify the dependency list.

The following snippet shows how to define a D3 scale. Should the object numButtons or boardSize change, the scale will be recomputed and anything that depends on the scale also.

File: /app/components/lights-out/component.js

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { scaleLinear } from 'd3-scale';

export default class LightsOutComponent extends Component {
    @tracked numButtons = { x: 5, y: 5 };

    get boardSize() { ... }

    get scaleX() {
        return scaleLinear()
            .domain([0, this.numButtons.x])
            .range([0, this.boardSize.x]);
    }

    get scaleY() {
        return scaleLinear()
            .domain([0, this.numButtons.y])
            .range([0, this.boardSize.y]);
    }
}
Enter fullscreen mode Exit fullscreen mode

A small print: Updating a complex data structure may require extra work. (It always did, to be fair.) To update buttons, a double array of objects, I made a deep copy and used set:

File: /app/components/lights-out/component.js

import { set } from '@ember/object';
import { copy } from 'ember-copy';

export default class LightsOutComponent extends Component {
    @tracked buttons;

    toggleLights(i, j) {
        let buttons = copy(this.buttons, true);

        // Center
        this.toggleLight(buttons[i][j]);

        // Top, Bottom, Left, Right
        ...

        this.buttons = buttons;
    }

    toggleLight(button) {
        set(button, 'isLightOn', !button.isLightOn);
    }
}
Enter fullscreen mode Exit fullscreen mode

b. Modifiers to the Rescue 💯

During the rewrite, I was worried that I would mess up the control flow again. In 2.18, I had introduced a mixin and turned a blind eye to Ember's then-13 lifecycle hooks. I had also relied on computed properties and observers to force the flow my way.

Since Glimmer components have 2 lifecycle hooks by default, I had much less to work with. The D3 component also needed to react to a window resize. I wasn't sure where I would now create and destroy the event listeners.

These problems went away as soon as I discovered more modifiers. ember-render-modifiers provides the {{did-insert}} modifier, and ember-did-resize-modifier the {{did-resize}} modifier.

Thanks to these two, I was able to write a declarative, observer-free code:

File: /app/components/lights-out/template.hbs

<div class="lights-out"
    {{did-insert this.setContainerSize}}
    {{did-insert this.drawGame}}
    {{did-insert this.startGame}}
    {{did-resize this.setContainerSize debounce=25}}
    {{did-resize this.drawGame debounce=25}}
>
    ...
</div>
Enter fullscreen mode Exit fullscreen mode

I can look at this template and be confident that, when the div element is added to the DOM, Ember will set the game container size, draw the game, then start it. (The order of appearance is respected.) Similarly, when the div element changes size, Ember will set the game container size and draw the game again.

It's interesting that, by having less, I could do more.

3. Conclusion

Octane, the first edition of Ember, has really shifted for the better how developers will approach writing, architecting, and testing their apps. I saw a glimpse when I rewrote two apps over a weekend. I'm looking forward to learn more about Octane at work, Meetups, and conferences in 2020!

Since the beginning, a core value within Ember has been accelerating (boosting, etc.—add as many octane-related puns as possible) your journey to productivity. Thanks to seemingly small things like named arguments and improved Ember Inspector, as well as big, ambitious things like modifiers and tracked properties, writing apps in Ember is easy and fun.

Again, if you have written tutorials and demo apps before, I encourage you to rewrite them in Octane and share what you learned. If you have never tried out Ember before, you can start out with the official, revamped Super Rentals tutorial. Feel free to ask for help on Discord at any time!

Resources

If you want to learn more about Octane, I encourage you to visit these links:

💖 💪 🙅 🚩
ijlee2
Isaac Lee

Posted on December 24, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Rewriting Apps in Ember Octane
ember Rewriting Apps in Ember Octane

December 24, 2019