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.
- A 404 catch-all route
- Routes that throw errors
- 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:
- URLs that couldn’t be recognized.
- URLs that could be recognized, but couldn’t find data.
- Routes that fail to load because of Ember Data.
- 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.