Skip to content

Instantly share code, notes, and snippets.

@chadhietala
Last active June 12, 2019 08:28
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chadhietala/50b977a7d3476069892d351c65af418c to your computer and use it in GitHub Desktop.
Save chadhietala/50b977a7d3476069892d351c65af418c to your computer and use it in GitHub Desktop.

Entering The Pit of Despair Incoherence

In the 2019 EmberConf keynote, Tom and Yehuda explained that Ember historically had a life-cycle where it appears things are moving too slowly and stagnating, or moving too quickly where there is a lack coherence of new APIs in respect to rest of the framework. To deal with this oscillation we have been working on editions, which is a moment in time where the framework is at maximum coherency. This gives the Core Teams the opportunity to do things like update the guides and documentation, but it is also a signal to the larger Ember community that the programming model has changed and it is now a good time to adopt the new concepts. In the summer we will reach this point of maximum coherency, so it's now time to think about moving past a coherent state and to descend into the the pit of incoherence once again. Below are some areas where I would like for us to spend time in 2019.

Routing

Ember's router has been the inspiration for many routing solutions in other frameworks, however I believe it's time to re-think the routing layer and make it feel more cohesive with the rest of the framework. While there is a large portion of the API that is nice there are noticable warts around the mental model of the router, routes, controllers and templates, query params, redirects/aborts.

Mental Model

Today the mental model for the routing layer is best described by the guides as:

(...) the Ember router then maps the current URL to one or more route handlers. A route handler can do several things:

  • It can render a template.
  • It can load a model that is then available to the template.
  • It can redirect to a new route, such as if the user isn't allowed to visit that part of the app.
  • It can handle actions that involve changing a model or transitioning to a new route.

One thing that is left out here is that it also instantiates a controller to back the routes template. This is a lot of stuff and I think it can be simpler. Let's first look at the first two items in the list above.

Data Fetching & Rendering

The primary role of the route is to acquire some data then take a controller as the backing context to the route's template and render them. But where else do we have this concept of a backing context and a template? Components of course! So in this new world we should likely should have route handlers invoke components.

Another thing that beginners need to learn is that there is {{this.model}} which is the result of the model() hook on the route and is made available to the template through it's backing class, the controller. In a world where we have routes invoking components we can simply state that routes can fetch data and invoke components with named arguments as defined in the route. This might look like:

// routes/profile.js
export default class extends Route {
  @service store;
  async arguments({ profile_id }) {
    return {
      profile: await this.store.findRecord('profile', profile_id),
    }
  }
}
{{! components/Profile.hbs }}

<h1>{{@profile.firstName}} {{@profile.lastName}}</h1>

So the keys of the object returned from arguments() become the named arguments to the component.

Redirecting

If we look at the 3rd responsibility from the docs we see that routes can do redirection away based on contextual data. To achieve this in a more unified model we can utilize the router service. To perform the redirection we do not need hooks on the route it self, instead we can just branch within the arguments hook and redirect.

// routes/profile.js
export default class extends Route {
  @service store;
  @service router;
  @service user
  async arguments({ profile_id }) {
    if (this.user.loggedIn) {
      return {
        profile: await this.store.findRecord('profile', profile_id),
      };
    } else {
      await this.router.transitionTo('login');
    }
  }
}

Actions

Finally Routes also have action handlers on them. However, the way that these actions get called is through the evented action system, which we are actively trying to move away from. Instead two patterns can be used:

  1. Return methods from the route
  2. Use actions in components

The first pattern is to uphold the patterns people use today to co-locate all data actions in the route. For instance you could do:

// routes/profile.js
export default class extends Route {
  @service store;
  @service router;
  @service user
  async arguments({ profile_id }) {
    return {
      profile: await this.store.findRecord('profile', profile_id),
      updateProfile: async (deets) => {
        let profile = await this.store.findRecord('profile', profile_id);
        profile.details = deets;
        await profile.save();
      }
    };
  }
}

updateProfile would become an argument to the component and could be invoked to update the profile. The second and more likely pattern is bit more straight forward, just inject the store and handle the persistent directly where you need it.

// app/components/profile-form.js

export default class ProfileForm extends Component {
  @servive store
  
  @action
  async updateProfile(deets) {
    let profile = await this.store.findRecord('profile', profile_id);
    profile.details = deets;
    await profile.save();
  }
}

Query Params

Finally, we get to our beloved Query Params. Today query params have some wierd quirks, like they are defined on a controller, but deserialized on the route, have default value serialization, hard to understand stickiness. I believe that with the introduction of the router service we can remove the rigidity of the existing API and just think of query params is global state that just get's deserialized from the url. As of 3.6.0 of Ember you can do the following:

// app/components/profile-form.js

export default class ProfileForm extends Component {
  @servive router
  
  @action
  async updateProfile(deets) {
    let { queryParams } = this.router.currentRoute;
    if (queryParams.mode === 'edit') {
      let profile = await this.store.findRecord('profile', profile_id);
      profile.details = deets;
      await profile.save();
    }
  }
}

The router service always has a currentRoute property on it that points to a RouteInfo object, which contains the deserialized query params on it. However, updating the query params is where we run into problems. Ideally we would either:

  • Make routerService#transitionTo not lookup the controller to validate query params

or

  • Create a routerService#queryParamsTransition that allowed to arbitrarily add/remove query params from the URL

NullVoxPopuli has been playing around with this very concept in ember-query-params-service and we should just make something like this the default way of dealing with query params in Ember.

By addressing these aspects of the routing system we can drastically reduce the complexity of the routing mental model. It removes long standing ask of making the system more component centric and provides the path to kill controllers.

Single File Format

RFC#454 talks about the potential of a single file format and I think this is long overdue. While having the separate files for different types can be nice, when it comes to components, helpers and modifiers, creating individual files for each one of these feels very heavy. For example if you have something like ember-basic-dropdown you have three different components that are highly coupled, but must be authored in a separate file. It would be much nicer if you could co-locate them together like so:

// addon/components/basic-dropdown.ember
import Component from '@glimmer/component';
import { on } from 'ember-on-modifier';

class BasicDropdownContent extends Component {
  @action
  handleClick() {
    // Snip
  }
  <template>
    <div ...attributes {{on "click" this.handleClick}}>{{yield}}</div>
  </template>
}

class BasicDropdownTrigger extends Component {
  @action
  handleClick() {
    // Snip
  }
  <template>
    <button ...attributes {{on "click" this.handleClick}}>{{yield}}</button>
  </template>
}

export default class BasicDropdown extends Component {
  <template>
   <div ...attributes>
   {{yield (hash 
       BasicDropdownContent=(component BasicDropdownContent)
       BasicDropdownTrigger=(component BasicDropdownTrigger))}}
   </div>
  </template>
}

While embedding the template into the component is the primary benefit, we reduce overhead of helpers. Helpers have the exact same exact runtime semantics and performance characteristics as @tracked. So we can begin to think about having more functional approaches to building applications with similar or better performance characteristics. For instance you could have a template-only component that leveraged several helpers:

// addon/components/hero-image.ember
import purify from 'dom-purify'

function imageUrl([ relativePath ]) {
  return purify(`http://cdn.example.com/${relativePath}`);
}

function truncate([content], { limit }) {
  let purfified = purify(content)
  return purfified.split(' ').slice(0, limit);
}

<template>
  <img alt={{@text}} src={{imageUrl @relativePath}} />
  <p>{{truncate @description limit=150}}</p>
</template>

By improving the ergonomics of authoring components we gain a better understanding of our applications are composed.

Let's Ship

While I could probably state a handful more things that I think it would be great to ship, I think what really need is ship the things we have talked about for years. While I think we are making progress on shipping stuff incrementally, we need to close out things like tree-shaking, svelte builds, SSR with incremental rehydration, stabilizing ember data, and a replacement for module unification. So heres to the journey into the incoherency pit, may we get out unscathed to see the light of a coherent framework once again.

@snewcomer
Copy link

This is phenomenal. Even though I recently bumped a large application from 2.18 to 3.9 without too much pain, I would have expected more pain. The best thing that could happen to Ember apps would be to put a little mental strain on us all so that we can advance together.

@sandstrom
Copy link

I really like these ideas for router improvements!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment