404 when route is known, but model is missing


#1

I’ve seen a couple different answers floating around the Internet for this situation. Figured I would ask here to get everyone’s current thinking.

I’ve got a router that looks like this.

Router.map(function() {
  this.route('post', { path: '/posts/:post_id' })

  this.route('404', { path: ':wildcard' });
});

And if I visit /this-url-isnt-found then the 404 route correctly displays. :+1:

However, if I visit /posts/this-id-doesnt-exist then my post route tries to render with an empty post. Here’s what the post route looks like:

export default Route.extend({
  model({ post_id }) {
    // there's no server, all posts are stored locally
    return this.store.peekAll('post').findBy('id', post_id);
  }
});

Currently, when the post isn’t found, the template tries to render with a null model.

I’ve currently solved this by using intermediateTransitionTo in the afterModel hook:

export default Route.extend({
  model({ post_id }) {
    // there's no server, all posts are stored locally
    return this.store.peekAll('post').findBy('id', post_id);
  },

  afterModel(post) {
    if (!post) {
      return new Promise(() => {
        this.intermediateTransitionTo('404', { wildcard: '404' });
      });
    }
  }
});

But I could only get this working if I wrapped it in a Promise, and tbh I’m not sure why it works. That made me think that maybe this approach is wrong.

I guess I have two questions:

  1. Is this the best way to handle a known route that has a missing model?
  2. Why does wrapping it in a Promise suddenly make it work?

Thanks!


#2

my guess for #2, from the docs:

if the value returned from this hook is a promise, the transition will pause until the transition resolves. Otherwise, non-promise return values are not utilized in any way.

EDIT: nvm guess that doesn’t necessarily answer the question of why it wouldn’t work before the return. Maybe something to do with the “intermediate” part of the transition?


#3

Ok, I have an update to this post!

It turns out returning a promise that never resolved put my tests in a bad state, so I abandoned that idea.

What I ended up doing was splitting the 404 error handling into a couple of different pieces.

  1. A 404 catch-all route
  2. Routes that throw errors
  3. A global error handler that can handle those thrown errors

Part 1: Catch-all route

Here’s the 404 catch-all route. It’s the last route in the file and it acts as a catch-all that handles any URLs that the router couldn’t recognize.

// app.router.js

Router.map(function() {
  // all other routes defined here!
  // ...

  // this is the last route in the file
  this.route('404', { path: ':wildcard' });
});

And here’s the template:

{{! app/templates/404.html }}

Opps! Page not found!

Part 2: Routes that throw errors

The next step was to deal with routes that could be recognized, but weren’t able to load the data in their dynamic segments.

For example, imagine this route:

// app.router.js

Router.map(function() {
  this.route('post', { path: '/posts/:post_id' });
});

What happens if a user visits /posts/999. The route can be recognized, but what if there’s no post with id 999?

There’s two ways of dealing with this type of error. The first is for routes that load data stored locally. If they fail to find the post, they’ll throw a not-found exception.

// app/routes/local-post.js

export default Route.extend({
  model({ post_id }) {
    // there's no server, all posts are stored locally
    let post = this.store.peekAll('post').findBy('id', post_id);

    if (!post) {
      throw "not-found";
    }

    return post;
  }
});

Next, let’s deal with a route that loads its data from a remote source using Ember Data. When no post is found this route will force Ember Data to throw an EmberError.

// app/routes/remote-post.js

export default Route.extend({
  model({ post_id }) {
    return this.store.findRecord('post', post_id);
  }
});

Part 3: Global error handler

Now for the fun part, handling these exceptions!

In the application route there’s a global error handler that will use intermediateTransitionTo if it catches a not-found or Ember Data 404 error.

The beauty of intermediateTransitionTo is it doesn’t change the URL. So the 404ing URL is kept in place, which is how server rendered applications behave.

Another cool part about this code is that it sets the http status code to 404 when running in Fastboot.

// app/routes/application.js

import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';

export default Route.extend({
  fastboot: service(),

  actions: {

    // this code handles any routes that throw exceptions
    error(error, transition) {
      let fastboot = this.get('fastboot');

      let notFound = error === 'not-found' ||
        (error &&
          error.httpErrorResponse &&
          error.httpErrorResponse.status === 404);

      // routes that can't find models
      if (notFound) {
        if (fastboot.get('isFastBoot')) {
          this.set('fastboot.response.statusCode', 404);
        }
        this.intermediateTransitionTo('404', { wildcard: '404' });

      } else {
        return true;

      }
    }
  }
});

In order to tell if Ember Data received a 404 our adapter creates an object on the exception called httpErrorResponse that contains the the server’s response. Without this I couldn’t find a way to distinguish between 404s, 401s, 403s, or any other http error for that matter.

Here’s the adapter code that sets httpErrorResponse on the error object.

// app/adapters/application.js

import DS from 'ember-data';

export default DS.JSONAPIAdapter.extend({

  handleResponse(status, headers, payload, requestData) {
    let responseObject = this._super(...arguments);

    if (responseObject && responseObject.isAdapterError) {
      responseObject.httpErrorResponse = {
        status, // <- this let's us know the adapter 404'd
        headers,
        payload
      };
    }

    return responseObject;
  }
});

This seems to be working well for all of the situations I’ve encountered, which are:

  1. URLs that couldn’t be recognized.
  2. URLs that could be recognized, but couldn’t find data.
  3. Routes that fail to load because of Ember Data.
  4. 404s rendered in Fastboot.

Part of me feels strange for using exceptions as a control flow mechanism, but it seems to be the best way to work through this problem.

I’d love to hear any thoughts to this approach.