Skip to content

Instantly share code, notes, and snippets.

@machty
Last active July 10, 2024 15:14
Show Gist options
  • Save machty/5723945 to your computer and use it in GitHub Desktop.
Save machty/5723945 to your computer and use it in GitHub Desktop.
Guide to the Router Facelift

Ember Router Async Facelift

The Ember router is getting number of enhancements that will greatly enhance its power, reliability, predictability, and ability to handle asynchronous loading logic (so many abilities), particularly when used in conjunction with promises, though the API is friendly enough that a deep understanding of promises is not required for the simpler use cases.

Why?

  • Semantic differences between app-initiated transitions and URL-initiated transitions made it very challenging in certain cases to handle errors or async logic
  • Authentication-based apps were especially difficult to implement
  • redirect was sometimes called when a promise model was resolved, sometimes not, depending on in-app/URL transition

Solution

The solution was to embrace async and make router transitions first class citizens. In the new API you are provided with the necessary hooks to prevent/decorate transition attempts via a Transition object passed to various hooks. These hooks are:

  • willTransition events fired on current routes whenever a transition is about to take place.
  • beforeModel/model/afterModel hooks during the async validation phase.

willTransition

All transitions types (URL changes and transitionTo) will fire a willTransition event on the currently active routes. This gives presently active routes a chance to conditionally prevent (or decorate) a transition. One obvious example is preventing navigation when you're on a form that's half-filled out:

App.FormRoute = Ember.Route.extend({
  events: {
    willTransition: function(transition) {
      if (!this.controller.get('formEmpty')) {
        transition.abort();
      }
    }
  }
});

model and Friends

Previous iterations of the router exposed a hook called redirect, which gave you the opportunity to transitionTo another route, thus aborting the present transition attempt. The problem with this is that when async data was involved, the behavior between transitionTo/linkTo behavior and URL navigation behavior was very different and not easily predictable. For instance, reloading the page or navigating with the back/forward buttons between routes with promises as models would pause the transition until the promise resolved (or rejected), but calling transitionTo would not pause the transition. This means that in some cases, redirect would be called with loaded models and in other cases, the data used to the decide whether the transition should be redirected wouldn't be loaded by the time redirect was called. This was problematic and often resulted in multiple, repetitive approaches to handling errors / redirect logic.

In this router iteration, transitionTo and URL changes behave the same way, in that any models provided via transitionTo or any models returned from the model hook will pause the transition if the model has a .then property (which indicates that it's a promise).

Error handling with error event

So what happens if a promise rejects? In previous iterations, this would look for a FailureRoute defined on your app namespace and then run its enter/setup handlers, essentially treating it as a global handler for all transition failures. Nowadays, you'll define an error event handler within the events hash on Ember.Route, which gets called if a promise returned from model (or the one you provided in transitionTo) rejects. (fwiw, it'll also fire if any errors are thrown in the model hook.) The error handler will be passed the error value/reason.

App.PostsIndexRoute = Ember.Route.extend({
  model: function(params, transition) {
    // Presently, this returns a promise-like object (it has
    // a `.then` property).
    return App.Post.find(123);
  },
  events: {
    error: function(reason, transition) {
      alert('error loading posts!');
      this.transitionTo('login');
    }
  }
});

This allows you to keep your error handling in one place; regardless of whether you URL-navigated into the route or you call transitionTo('posts.index', App.Post.find(123)), if there's an error with the model promie, the same error hook will get called.

Last but not least, since error is an event, errors thrown (or promises rejected) from leafier routes that don't have error handlers defined will bubble up to the nearest parent with error defined. This allows you to share common error handling logic between a hierarchy of routes. Note that if so desired, you can continue to bubble an error (or any event) by returning true from the handler.

Global Error Handling

We got rid of FailureRoute, which was barely documented, kind of misleading, and not all that useful (most people just used it for redirecting anyway). But if you still want global shared error handling logic, you can just define the error handler on ApplicationRoute.

App.ApplicationRoute = Ember.Route.extend({
  events: {
    error: function(reason, transition) {
      this.controllerFor('banner').displayError(reason);
    }
  }
});

Note that if you don't specify your own, a default handler will be supplied that just logs the error and throws an exception on a setTimeout to escape from the internal promise infrastructure.

beforeModel and afterModel

Oftentimes, you might have enough information to redirect or abort a transition before ever trying to query the server. One example is if the user follows a link to "/posts/123" but has never retrieved an auth token to view these private posts, you don't want to waste a query to the server and have to wait for its return value to come back. Rather, you can make this check in the beforeModel hook:

App.PostsIndexRoute = Ember.Route.extend({
  beforeModel: function(transition) {
    if (!this.controllerFor('auth').get('token')) {
      this.transitionTo('login');
    }
  }
});

On the flip side, there might be some redirect logic that can only take place after a router's model promise has totally resolved, in which case you'd want to use the afterModel hook.

App.PostsIndexRoute = Ember.Route.extend({
  afterModel: function(posts, transition) {
    if (posts.length === 1) {
      this.transitionTo('post.show', posts[0]);
    }
  }
});

Transitions as Promises

transitionTo returns a Transition object, and Transition is a promise (it has a .then property that you can attach resolve/reject handlers to). If you need to run code after a transition has succeeded (or failed), you have to use .then. Example:

// From, say, an event in the `events` hash of an `Ember.Route`
var self = this;
this.transitionTo('foo').then(function() {
  self.router.send('displayWelcomeMessage');
}, function(reason) {
  logFailure(reason);
});

Abort and retry transitions

You can abort an active transition by calling .abort() on it. This will halt a transition without redirecting there. Note that performing another transitionTo while a previous transition is in process will immediately cancel the previous transition (so you don't need to call .abort() on the original transition before invoking a new transitionTo).

If you save a reference to a transition, you can re-attempt it later by calling .retry() on it, which returns a new Transition object (which you can call .abort()/retry() on, etc).

Advanced: Promises in hooks for fine-grained async/error handling

The aforementioned beforeModel/model/afterModel hooks already give you the opportunity to abort or redirect the transition elsewhere, but they also let you manage some pretty complex async operations and error handling if you have an understanding of promises.

If you return a promise from any of these hooks, the transition won't proceed to the next step until that promise resolves, and if it rejects, the error hook will be called with its reject value. If all the possible errors that can go wrong can be well-handled in the single error hook, then look no further, but if, say, you perform very particular async logic in beforeModel or afterModel, and you want their errors to be handled in a particular way, you can return a promise that's already had .then called on it, e.g.

App.FunkRoute = Ember.Route.extend({
  beforeModel: function(transition) {
    var self = this;

    // Load some async Ember code that FunkRoute needs.
    return loadAsyncCodePromise().then(null, function(reason) {
      // We can do any number of things here, with different
      // implications:
      // 1) self.transitionTo('elsewhere'); 
      //    - aborts the transition, redirects to elsehere
      // 2) transition.abort();
      //    - aborts the transition
      // 3) return Ember.RSVP.reject("some reason");
      //    - aborts the transition, calls the `.error` hook with
      //      "some reason" as the error
      // 4) throw "some reason";
      //    - does the same as (3)
    });
  }
});
@evilmarty
Copy link

Will there still be the global FailureRoute or something like that still or does every route have to handle errors?

@machty
Copy link
Author

machty commented Jun 14, 2013

@evilmarty I removed it. Should I add it back in? It seems highly at odds with the new approach and was never really documented (can't find any reference to it on the Emberjs.com docs or on StackOverflow anywhere).

@evilmarty
Copy link

@machty don't worry about it. I like the event process better, and it bubbles up to the application route, which is kind of the same thing but IMO a lot neater. Awesome work!

@jgwhite
Copy link

jgwhite commented Jun 17, 2013

@evilmarty plus, if a global FailureRoute were desirable, it would be super-easy to implement with the new API.

Thanks again @machty, this is such awesome work.

@dechov
Copy link

dechov commented Jun 26, 2013

Thank you -- I was just dealing with transitionTo not working how I wanted, and upgrading to rc.6 fixed it.

@jmonma
Copy link

jmonma commented Jun 28, 2013

@machty This is awesome stuff. Thanks for coming to talk about it at NYC training.

@workmanw
Copy link

@machty tweeted this out a few weeks back. Seems to help clarify the inner workings of this change IMHO: http://f.cl.ly/items/2J1h3m0C2t3n0d3x2b1p/AsyncRouterLinkTo.jpg

@coladarci
Copy link

Huge help - I'm assuming this will help in allowing me to do a lot more when it comes to handling partially loaded models (let's say you want a teaser loaded for a list versus the full model loaded for the detail view).

@machty mentions that this will help with "Authentication-based apps" - anyone have any luck securing certain routes and leaving others open based on the user's auth-state?

@yial2
Copy link

yial2 commented Jul 10, 2013

Ember beginner here. Can someone kindly elaborate how this solve @eviltrout issue emberjs/ember.js#1856 on http://jsbin.com/oroyup/4/edit. I am using RC6 with his sample code, still getting the Uncaught TypeError: Object [object Object] has no method 'addArrayObserver'
------------[Updated]----------------
My mistake, @eviltrout example is actually solved with RC6. The error occurs because I tried to pass parameter in transitionToRoute and have a serialize hook in the route I am transitioning to.

@huafu
Copy link

huafu commented Aug 14, 2013

I'm still getting into redirect or afterModel hooks with promises as models. Especially when more than one level of routes nested which need the same scenario:

App.Router.map ->
  @resource 'categories', ->
    @resource 'category', path: ':category_id', ->
      @resource 'composites', ->
        @resource 'composite', path: ':composite_id', ->
          @resource 'questions', ->
            @resource 'question', path: 'question_id', ->
              @route 'index', path: '/'

# all plural resources routes are defined like that:
App.CategoriesRoute = Em.Route.extend
  model: ->
    App.Category.find() # no parameter as it's the root resource
# example for a nested plural one:
App.CompositesRoute = Em.Route.extend
  model: ->
    App.Composite.find category_id: @modelFor('category').get('id')

# and singular resource routes aren't defined so they follow the pattern:
App.CompositeRoute = Em.Route.extend
  @model: (params) ->
    App.Composite.find(params.composite_id)

# I want to redirect plural routes to the directly following singular route using the first loaded model
# I tried redirect in each level in many ways and here are some:
App.CategoriesIndexRoute = Em.Route.extend
  @redirect: -> # I tried the @afterModel hook too here but same same
    @transitionTo 'category', @modelFor('categories').get('firstObject')
    # but I end up with @modelFor('categories') being still a promise (it has a then method) and the property
    # isLoaded is true, but as is the property isUpdating, and of course the length is 0 and I can't grab the first
    # object whereas there is one loaded later). This is the same scenario for each level

# to redirect singular routes to the directly following plural one and at least this is working:
App.CompositeIndexRoute = Em.Route.extend
  @redirect: ->
    @transitionTo 'questions'

I also posted a question on stack overflow related to this where there might be a bit more informations, and as it's with ember-data and especially promises, I wasn't able to do a jsFiddle about it and I'm not sure it'll be possible. Here is the SO question: http://stackoverflow.com/questions/18222762/best-practices-with-deep-routes-in-ember-with-ember-data.

Thanks in advance for any help you could provide, i'm kinda stuck here :-s

@huafu
Copy link

huafu commented Aug 14, 2013

Ok got it working in a weird way, take a look at the SO question, i've answered my own question, not sure it's the best way, just lemme know if it has to be like this or not ;-)

http://stackoverflow.com/questions/18222762/best-practices-with-deep-routes-in-ember-with-ember-data

Copy link

ghost commented Sep 5, 2013

Love the new hooks, but I'm seeing that the application template is not being rendered until the beforeModel hook has resolved. Is this expected? In my app, the application template was showing a splash screen, which was disabled after the model was loaded. Any suggestions on how to do that with the latest router? Thanks, Andrew

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